Compare commits

..

38 Commits

Author SHA1 Message Date
benstull b6bf970684 Merge pull request 'feat(altitude): auto-advance when the altitude video completes (not a 60s timer)' (#36) from feat/auto-advance-on-video-complete into main 2026-07-03 20:00:23 +00:00
BenStullsBets 34fb48cdec feat(altitude): auto-advance when the altitude's video completes (not a timer)
Replace the fixed 60s idle timer with video-completion pacing: when the base loop
video finishes one playthrough (the timeupdate wrap point, or the ended safety
net), drift one altitude deeper (descend, wraps) — gated by the default-on toggle
and deferred during a manual drag or a playing morph. Removes the whole
AUTO_ADVANCE_MS/scheduleAutoAdvance/bump timer machinery and its interaction
resets. loop-recovery.spec disables the toggle to test the loop-back path in
isolation. auto-advance.spec reworked to the completion-driven model.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 13:00:14 -07:00
benstull c8416ef2e9 Merge pull request 'feat(audio): Music shuffles to a new random track when each finishes (gapless)' (#35) from feat/music-shuffle-on-end into main 2026-07-03 13:00:47 +00:00
BenStullsBets e666465d62 feat(audio): Music plays each track to its end, then a random next (gapless)
Replace the fixed 5-min rotation with end-driven shuffle: each track plays through,
and a short crossfade a few seconds before it ends rolls a random OTHER track
(pickNextIndex, no immediate repeat) — so Music is always playing while selected.
An 'ended' handler is the safety net (short track / missed timeupdate) and a
watchdog resumes an unexpectedly-paused active track. Crossfade injectable via
window.__hefMusicXfadeMs. music-playlist.spec.ts reworked to the end-driven model.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 06:00:27 -07:00
benstull 8c6778230b Merge pull request 'fix(build): include the Music playlist in the static media sync' (#34) from feat/experience-tuning into main 2026-07-03 01:02:40 +00:00
BenStullsBets 89cd6aa1dd fix(build): include the Music playlist in the static media sync
The Music-option tracks are referenced by app.js (MUSIC_PLAYLIST), not the
manifest/ring, so build_static skipped them — Music would 404 on the static
site. Add audio/music/<name>.mp3 explicitly. +1 pytest.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 18:02:22 -07:00
benstull 04c8855ddc Merge pull request 'Experience tuning: mood-through-morphs fix, auto-advance, label wrap, binaural candidates' (#33) from feat/experience-tuning into main 2026-07-03 01:00:21 +00:00
BenStullsBets da3581afea feat(audio): add the Audio source dropdown to the welcome screen
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>
2026-07-02 16:56:17 -07:00
BenStullsBets 4f4f21a767 feat(audio): Music becomes a crossfading playlist of 5 mellow synth tracks
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>
2026-07-02 16:22:28 -07:00
BenStullsBets 969236b3f7 feat(audio): shareable ?audio= deep-link presets the sound mode
?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>
2026-07-02 16:00:59 -07:00
BenStullsBets b0bd784305 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 <noreply@anthropic.com>
2026-07-02 15:32:23 -07:00
BenStullsBets 04c3a133c3 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 <noreply@anthropic.com>
2026-07-02 14:20:31 -07:00
BenStullsBets 028d02a033 docs(audio): add Round-2 musical/evolving binaural candidates
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 <noreply@anthropic.com>
2026-07-02 06:43:01 -07:00
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
benstull f6b45b6cd7 Merge pull request 'feat(audio): Audio source dropdown (None/Soundtrack/Music) + Volume rename' (#32) from worktree-audio-music-option into main 2026-07-02 05:28:11 +00:00
BenStullsBets d161db8110 feat(audio): add CC0 ethereal Music loop (freesound #808032, via LFS)
The Music-option asset produced by build_audio_media.py: deadrobotmusic
'Ambient F Sharp Minor Ethereal Choir Pad' (CC0), sourced from the freesound
hq preview and looped/loudness-normalized to ethereal.loop.mp3 (3:34). Both
the raw source and the served loop are committed via git-LFS, matching the
per-scale soundtracks; served at /media/audio/music/ethereal.loop.mp3.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 22:26:14 -07:00
BenStullsBets ee7190bb4a feat(audio): Audio source dropdown (None/Soundtrack/Music) + Volume rename
Split the single 0–10 'Audio' slider into two controls:
- Audio  — a dropdown selecting the source: None / Soundtrack / Music
- Volume — the renamed 0–10 slider (master gain for the active source)

Music is one ethereal, relaxing ambient loop played the same at every
altitude (no per-altitude crossfade), per the innerflo.me brief. Sourced
CC0: deadrobotmusic 'Ambient F Sharp Minor Ethereal Choir Pad' (freesound
#808032); added to build_audio_media.py, the candidate-pool doc, and the
public credits colophon. Media stays gitignored; run build_audio_media.py.

Routing lives in a pure, node-tested HEFScrub.audioPlan(source, level, frac);
app.js's restAudio/blendAudio consult it. None greys out Volume. The welcome
screen's mirror level slider is relabeled Volume too. i18n for all 4 langs.

Spec: docs/superpowers/specs/2026-07-01-audio-music-option-design.md
Tests: +5 node unit (audioPlan), +4 e2e (audio-source.spec.ts); 32 related
e2e green, 38 node unit green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 21:55:23 -07:00
BenStullsBets 6816e4ce75 docs(spec): Audio source dropdown (None/Soundtrack/Music) + Volume rename
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 21:41:40 -07:00
BenStullsBets 5226a1ebd5 add sessions/0039/SESSION-0039.0-TRANSCRIPT-2026-06-30T10-40--2026-06-30T23-52.md + replace placeholder/variant SESSION-0039.0-TRANSCRIPT-2026-06-30T10-40--INPROGRESS.md 2026-07-01 00:02:22 -07:00
BenStullsBets 6695f32b74 claim human-experience-filter-art session 0039 (placeholder) + sessions.json entry 2026-06-30 23:55:54 -07:00
BenStullsBets 7ec8b607d2 Merge remote-tracking branch 'origin/main' into worktree-welcome-screen-controls 2026-06-30 15:06:59 -07:00
BenStullsBets b135cb5f5e fix(deploy): redirect the bare legacy root too (/* splat misses the empty case)
Add an explicit /human-experience-simulator/ → /human-machine-strata/ 301 (the
subpath splat doesn't match the trailing-slash root).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 15:02:29 -07:00
BenStullsBets 950c1e6c8c feat(deploy): move app URL to /human-machine-strata + 301 from the legacy path
Build default app-path → /human-machine-strata; deploy.sh APP_PATH default too.
_write_redirects now emits a permanent redirect /human-experience-simulator/* →
/human-machine-strata/:splat so old links still land. Media (R2 bucket) + Pages
project names are unchanged (not in the benstull.art URL). Updated the build
test (asserts the legacy redirect) + static-build e2e path + README paths.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 15:00:40 -07:00
BenStullsBets 958c3060d2 feat(rename): retitle the project to "Human/Machine Strata"
Page <title>, on-page <h1> (app.title — kept as the proper title, same across
all 4 languages), the About + Credits headings/prose, and the author dev tool.
"Strata" = the layered cosmos→abyss descent; the slash = the human↔machine
membrane the work is about.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 15:00:40 -07:00
BenStullsBets c36f995463 feat(panel): move Altitude to the top of the panel (mobile: reachable with video on screen)
On mobile the panel wraps below the (sticky) stage; putting the Altitude knob
first means it sits right under the video instead of behind Output + knobs.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 14:29:02 -07:00
BenStullsBets b63518bfbb chore(title): h1 header → "Human Experience Filter Simulation" (4-lang, matches page title)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 14:23:43 -07:00
BenStullsBets 4555bb29a8 chore(title): page title → "Human Experience Filter Simulation"
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 14:22:51 -07:00
BenStullsBets 8c63b14967 chore(deploy): no-store the whole app path (per-HTML override didn't stick)
Cloudflare Pages let the /seg/* no-cache rule win over the per-HTML no-store, so
the index was still served no-cache. no-store the whole app path instead — the
shell is tiny and media is on R2 (separate, immutable). Guarantees a fresh index.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 14:20:53 -07:00
BenStullsBets ecd444b53b fix(reduce-motion): re-roll the clip on an instant-cut altitude change
With reduce motion on, selecting a new altitude jumps (autoScrub's reduced
branch → setPos) and skips the segment that would re-roll the destination clip,
so activeClipId stayed on the previous altitude's clip and the video didn't
change. Self-heal at the landing point: if the landed altitude's pool doesn't
contain activeClipId, pickPoolClip a fresh one. TDD: new altitude-lock e2e.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 14:18:16 -07:00
BenStullsBets fafb498d6f feat(panel): move Experience knobs above Altitude; drop dial hint + '(0–4)'
Per operator: Think/Feel/Mood group sits above the Altitude knob; remove the
'Turn the knob…' altitude hint (and its dead i18n key); 'Experience knobs (0–4)'
→ 'Experience knobs' (all 4 langs).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 14:12:58 -07:00
BenStullsBets e06191db32 chore(deploy): no-store the app-shell HTML (stop Safari serving a stale index)
no-cache (revalidate) wasn't enough — Safari served a stale cached index from
disk/bfcache, which loaded old versioned JS and broke the page (black, silent).
HTML entry points now no-store (always fresh index → always fresh hashed assets).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 14:12:57 -07:00
BenStullsBets ccd40103a2 fix(speed): cap Video speed at 2x (>2x stutters — base loops not all-intra)
Same root cause that retired reverse playback: the resting loop clips aren't
all-intra, so playbackRate >2x makes Safari stutter on inter-frame seeks. Narrow
both sliders (welcome + panel) + the tick datalist to 0–2x; default still 1x.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 14:08:11 -07:00
BenStullsBets 79f64e3f7c feat(welcome): rename "Speed" control to "Video speed" (4-lang)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 13:57:13 -07:00
BenStullsBets 54cad607fb add sessions/0038/SESSION-0038.0-TRANSCRIPT-2026-06-30T12-25--2026-06-30T13-20.md + replace placeholder/variant SESSION-0038.0-TRANSCRIPT-2026-06-30T12-25--INPROGRESS.md 2026-06-30 13:19:40 -07:00
BenStullsBets 053bb5c1a3 claim human-experience-filter-art session 0038 (placeholder) + sessions.json entry 2026-06-30 13:17:25 -07:00
38 changed files with 1405 additions and 103 deletions
+2
View File
@@ -23,6 +23,8 @@ 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
simulator/static/review_music.html
# Editor annotation/comment-thread metadata (not project content)
.threads/
+4 -4
View File
@@ -1,7 +1,7 @@
# Deploying to benstull.art — Cloudflare Pages + R2
Publishes the simulator as a fully-static site: the frontend on **Cloudflare
Pages** (`benstull.art`, app at `/human-experience-simulator`, apex redirects to
Pages** (`benstull.art`, app at `/human-machine-strata`, apex redirects to
it) and the video/audio on **R2** behind `static.benstull.art`. No origin server.
Design: `docs/superpowers/specs/2026-06-30-cloudflare-static-publish-design.md`.
@@ -48,7 +48,7 @@ as a script):
```
Produces:
- `dist/` — Pages output: `dist/human-experience-simulator/` (frontend + baked
- `dist/` — Pages output: `dist/human-machine-strata/` (frontend + baked
`clips.json`/`ring.json`/`media-versions.json` + generated `config.js`) and a
root `dist/_redirects` (apex → app path, and no-slash → slashed).
- `dist-media/` — R2 upload tree, ~2.3 GB / 604 files, manifest-referenced only
@@ -109,7 +109,7 @@ Then **in the dashboard** (Workers & Pages → project → Custom domains) — t
no wrangler CLI for Pages custom domains:
- Bind **`benstull.art`** to the project. (Allow a few minutes — a fresh bind
serves `522` until the cert/routing provisions.)
- The app serves at `benstull.art/human-experience-simulator/`; the shipped
- The app serves at `benstull.art/human-machine-strata/`; the shipped
`dist/_redirects` sends `/` and the no-slash form to it.
## 4. Verify before/after going live
@@ -121,7 +121,7 @@ curl -sI -H "Origin: https://benstull.art" -H "Range: bytes=0-1" \
```
Open `https://benstull.art/` → should redirect to
`https://benstull.art/human-experience-simulator/`. Turn the **Feel** knob to max
`https://benstull.art/human-machine-strata/`. Turn the **Feel** knob to max
and confirm the dream effect renders (no `SecurityError` in the console — that
would mean CORS is misconfigured and the GL texture is tainted). The local
static-build E2E (`simulator/e2e/tests/static-build.spec.ts`) exercises this same
+1 -1
View File
@@ -15,7 +15,7 @@ set -euo pipefail
BUCKET="${BUCKET:-human-experience-simulator-media}"
PROJECT="${PROJECT:-human-experience-simulator}"
MEDIA_BASE="${MEDIA_BASE:-https://static.benstull.art/}"
APP_PATH="${APP_PATH:-/human-experience-simulator}"
APP_PATH="${APP_PATH:-/human-machine-strata}"
BRANCH="${BRANCH:-main}"
JOBS="${JOBS:-10}"
+30 -3
View File
@@ -9,8 +9,10 @@ model — random pick per landing, or pairable per-clip).
The "audio + video" setting layers **two** kinds of audio per altitude:
1. **Ambience / audio track** — the soundscape (waves, reef crackle, chorus, etc.).
**This is what we're sourcing now.**
2. **Music track** — a separate composed musical layer. **Deferred** until the
ambience tracks are settled across all altitudes.
2. **Music track** — a separate composed musical layer. **Shipped** (2026-07-01)
as the Audio-dropdown **"Music"** option: unlike the earlier per-altitude idea,
it is now **one single ethereal loop played the same at every altitude** (see
§ Music below).
Pools below are the **ambience** layer unless marked `music`. **Distinction (operator):**
the ambience/soundtrack layer is an *environmental, real-world soundscape* (waves, reef,
@@ -48,6 +50,28 @@ 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)
> **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
stay as historical candidates). The operator's brief: *ethereal, relaxing — like
innerflo.me.* CC0 (operator's sourcing choice), so attribution is courtesy, not required.
| id | clip | source | dur | license |
|---|---|---|---|---|
| `music/ethereal` | **✓ KEEP** — "Ambient F Sharp Minor Ethereal Choir Pad 1": blurry, paulstretched layered choir — "peaceful, magical, soothing" | deadrobotmusic — https://freesound.org/people/deadrobotmusic/sounds/808032/ (hq preview: `cdn.freesound.org/previews/808/808032_*-hq.mp3`) | 3:38 | CC0 |
Production: `simulator/build_audio_media.py` `SOURCES["music"]` → seamless loudness-normalized
loop at `audio/music/ethereal.loop.mp3`, served `/media/audio/music/ethereal.loop.mp3`
(client fallback: `app.js` `MUSIC_FALLBACK`). Download the sound page's WAV/MP3 to
`simulator/sample_media/audio/music/ethereal.mp3`, then `python simulator/build_audio_media.py`.
## Sourcing rule (learned 2026-06-26)
**Source only from a browsable page the operator can open and whose license is stated.**
@@ -67,7 +91,10 @@ hubs: NASA SVS / Chandra, NOAA PMEL/SanctSound (PD), BigSoundBank (CC0), Pixabay
- **reef** — ✅ round-4 6-layer bake (`reef/soundscape`): NOAA PD biological layers + a CC0
submerged underwater "you're under" bed (DCSFX freesound #366159, hq preview). License clean.
SanctSound clips: `files/SanctSound_*.mp4` at `https://sanctsound.ioos.us/files/` (extract audio).
- **music layer** — separate composed music track per altitude, deferred (cosmos slot reserved).
- **music layer** — ✅ SHIPPED 2026-07-01 as the Audio-dropdown "Music" option, but as
ONE altitude-independent ethereal loop (`music/ethereal`, CC0), not the per-altitude
track once envisioned. See § Music above. (The `cosmos/music` / "Frozen Star" rows are
now historical per-altitude candidates, not planned work.)
- **Production (after picks):** loop seams, length normalization, cross-altitude crossfade
(mirror video); cosmos/abyss clips are short → loop.
+110
View File
@@ -0,0 +1,110 @@
# 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."
**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`.
## Round 2 — musical / evolving (the "not boring" set, 2026-07-02)
Operator found the pure-tone set (#17) 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 #810 (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 &
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.
- **Round 2 NC drops** (great content, non-commercial licence): "Fear Nothing (Relaxation
Mix)" — Jeris (CC BY-NC), "On the Earth" — Quarkstar (CC BY-NC), "Timeless (Binaural
Beatmix)" — duckett (CC BY-NC), "Ambient Meditation" — Scott Holmes (CC BY-NC). ccMixter's
binaural tag is overwhelmingly NC; only shagrugge (CC BY) survived.
## 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]]).
+129
View File
@@ -0,0 +1,129 @@
# 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.
## FINAL selection — WIRED 2026-07-02
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
- **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.
## 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/<Title>.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)
| 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.
@@ -0,0 +1,113 @@
# Audio "Music" option + "Volume" rename — design
*Date: 2026-07-01 · Status: approved design, awaiting spec review*
## Goal
Give the listener a choice of what to hear. Today the Output panel has a single
010 slider labeled **Audio** that acts as the master gain for the per-altitude
ambience crossfade. We split that into two controls:
- **Audio** — a dropdown selecting the audio *source*: **None · Soundtrack · Music**
- **Volume** — the existing 010 slider (relabeled), master gain for whatever
source is active
**Music** is one ethereal, relaxing ambient loop (the innerflo.me vibe) that
plays continuously and does **not** change with altitude.
## Non-goals
- No per-altitude music set (Music is a single track, not a musical Soundtrack).
- No change to the Soundtrack behavior itself (per-altitude crossfade stays exactly as-is).
- No new audio elements — the existing `#aud` / `#aud-b` pair is reused.
## Control surface
The Output `<fieldset>` (`simulator/static/index.html`) gains a dropdown above the
renamed slider:
```
Output
[ Video ] (toggle)
Audio [ Soundtrack ▾ ] ← NEW: <select id="audio-source">
Volume ●──────── 2/10 ← existing #audio slider, label → "Volume"
Speed ●──────── 1.00×
🌐 [ Language ▾ ]
```
- The dropdown is a `<select id="audio-source">` with options `none` / `soundtrack`
/ `music`, default **`soundtrack`** (preserves today's launch experience).
- The slider keeps `id="audio"` (avoids touching every reference) but its visible
label becomes **Volume** (new i18n key `output.volume`). The `output.audio` key
("Audio") now labels the dropdown.
## Behavior by mode
| Mode | Behavior |
|---|---|
| **None** | Both `#aud`/`#aud-b` fade to silence — the existing `audioLevel()===0` path. The Volume slider is disabled (greyed). |
| **Soundtrack** | Unchanged: per-altitude ambience crossfading across the two elements as the dial moves (`blendAudio` / `restAudio`). |
| **Music** | The single music loop plays on `#aud` at master volume; `#aud-b` fades out. Altitude changes do **not** crossfade or reload audio. |
Volume = 0 remains a hard mute in every mode.
## Code shape
All in `simulator/static/app.js`, following the existing audio-layer seams
(`restAudio`, `blendAudio`, `ensurePlaying`, `scaleAudioUrl`):
- **`audioSource()`** — new helper reading `#audio-source``"none" | "soundtrack" | "music"`.
- **`musicUrl()`** — returns the resolved music loop url via `mediaBase()`
(mirrors `scaleAudioUrl`'s R2/local routing), e.g. `mediaBase()+"audio/music/<name>.loop.mp3"`.
- **`restAudio(index)`** branches:
- `none` (or `audioLevel()===0`) → existing fade-to-silence.
- `music``ensurePlaying(aud, musicUrl(), audioVol())`; `fadeVolume(audB, 0, …)`.
- `soundtrack` → unchanged per-altitude logic.
- **`blendAudio(lo, frac)`** returns early (no crossfade) when source is `music` or
`none`; only Soundtrack blends across the altitude boundary.
- **Dropdown wiring**: `#audio-source` `change` → reconcile synchronously in the
gesture (`applyAudio()`), same Safari-unlock discipline the slider already uses;
toggles the Volume slider's `disabled` when None.
- **`beginExperience()`**: unchanged except it snaps **Volume** (`#audio`) to the
start level 2; default source stays Soundtrack.
- **Diagnostic readout** (`audioStatusText`): prefix with the current source
(`none`/`soundtrack`/`music`) so a "no sound" report stays legible.
No server/engine change — audio source is a pure client concern.
## Music asset & licensing
- One **CC0 / public-domain** ethereal ambient loop (innerflo.me-style: soft,
evolving, no percussion, seamless loop). Sourced by the implementer; reviewed by
ear before ship.
- Stored at `simulator/.../media/audio/music/<name>.loop.mp3` alongside the
per-scale soundtracks, and registered in the credits manifest exactly like every
other audio asset (this project credits all CC/PD audio — see `credits.html`).
- Fallback path constant mirrors the `SCALE_AUDIO_FALLBACK` pattern so Music works
even against an older server process.
## i18n + a11y
- New key `output.volume` (en: "Volume", es: "Volumen", fr: "Volume", ja: "音量").
- Dropdown option labels localized in en/es/fr/ja:
- None (Ninguno / Aucun / なし)
- Soundtrack (Banda sonora / Bande-son / サウンドトラック)
- Music (Música / Musique / 音楽)
- `<select id="audio-source">` gets an `aria-label` tied to the Audio label.
- Reduced-motion behavior is unaffected (audio is orthogonal to motion).
## Tests
- **Node unit** (`simulator/unit/…`): `audioSource()` routing and the
`blendAudio`/`restAudio` branch selection — none→silent, music→single-element +
no crossfade on altitude change, soundtrack→unchanged.
- **E2E** (`simulator/e2e/…`): dropdown switches modes; Music produces a single
playing element that persists across an altitude change; None disables the Volume
slider and silences audio; the slider label reads "Volume".
## Decisions (confirmed with operator)
1. **Music = one continuous, altitude-independent loop** (not per-altitude, not ducked).
2. Default Audio source = **Soundtrack**; Music is opt-in.
3. **None** disables the Volume slider.
4. Music track is **operator-approved CC0** sourced by the implementer.
@@ -0,0 +1,102 @@
# Session 0038.0 — Transcript
> App: human-experience-filter-art
> Start: 2026-06-30T12-25 (PST)
> Type: planning-and-executing
> Posture: yolo
> Claude-Session: 52b811fa-0926-4619-ae31-3c71d489c686
> Checkout: /Users/benstull/git/benstull.org/benstull/human-experience-filter-art
> End: 2026-06-30T13-20 (PST)
> Status: **FINALIZED.**
## Summary
Added a **Playback Speed slider** to the simulator's Output panel, replacing the
"Reduce motion" toggle. Designed (brainstorm → spec), built TDD-first, shipped to
`main`, then **revised the scale after operator eyeball**: the initial 2×…2×
range with a manual reverse loop jittered in a real browser (base loop clips
aren't all-intra), so it was narrowed to **0×–4× forward** and the reverse
machinery removed.
### Arc
1. **Brainstorm** (no tracked init this session — claimed s0038 at finalize).
Surfaced the real tradeoff: "Reduce motion" is the a11y safety control
(`isReduced()`), doing three things beyond freezing the loop (OS-pref default,
instant transitions, photosensitivity floor). Two operator decisions via
AskUserQuestion: **keep a11y but move it** (derive from OS
`prefers-reduced-motion`, no visible toggle) and **implement a true manual
reverse loop** for the negative range.
2. **Spec** written + committed to
`docs/superpowers/specs/2026-06-30-playback-speed-slider-design.md`.
3. **Build (round 1, 2…2 + reverse), TDD:** worked in worktree
`feat/playback-speed-slider` (off `origin/main`). Pure node-tested
`HEFScrub.reverseStep` helper (RED→GREEN); slider + datalist in `index.html`;
`applyPlaySpeed` + rAF reverse loop in `app.js`; `isReduced()` re-derived from
OS pref; retired `hef.reduceMotion`; i18n `speed.label` + reworded `warn.body`.
Verified 14/14 node unit, 13/13 a11y e2e (Task 5 rewritten), static-build e2e
2/2 (built the dist bundle to confirm). Merged to `main` (`df8b245`) —
concurrent session pushed mid-merge, so rebased onto the new tip and pushed.
4. **Operator eyeball:** reverse was jittery; operator chose **0×–4×** as the
scale.
5. **Build (round 2, 0×–4× forward):** worktree `feat/speed-forward-0-4x`.
Removed the reverse rAF loop + `reverseStep` helper + its 4 unit tests;
slider `min=0 max=4`; datalist ticks at 0.5; spec annotated with a revision
note. Verified 10/10 node unit, 12/12 a11y e2e (against the actually-served
worktree code). Merged to `main` (`e064a2e`, clean FF) and pushed.
6. Served the merged code locally at `http://localhost:8099/` for re-view both
rounds.
### Pipeline (§9)
This app is **exempt** from flotilla/PPE/§9 prod gate — public target is static
Cloudflare (Pages + R2). Change is merged to `main`; **operator redeploy** of the
static build is the remaining ship step. E2E browser tests (Playwright) were run
locally and are green. No plan artifact archived: design lived in the spec
(already in this repo's `specs/`, merged); app has no `content` repo.
### Next session
Operator by-eye review of the 0×–4× speed feel on the live sim, then redeploy to
Cloudflare to publish it.
## Launch prompt
> Let's add a "Playback Speed" slider that goes from -2x (which would play
> backwards) to 2x, with increments of 0.25. This only applies to the altitude
> videos, not the morph videos. This can replace the "Reduce motion" toggle
_(Mid-session the operator refined: continuous drag rather than 0.25 detents;
then, after eyeballing reverse jitter, narrowed the scale to 0×–4× forward.)_
## Plan
> Anchor: leaf `feature`/`task` (UI control on an existing surface). Design →
> spec → TDD build → ship branch → PR/merge → operator redeploy.
Continuous Playback Speed slider driving ONLY the resting altitude loop video
(`#vid-loop`); morph/transition video (`#vid`) is scrub-driven and untouched.
Reduced-motion accessibility preserved but derived silently from OS
`prefers-reduced-motion` (slider disabled when active). Shipped 2…2 with a
manual reverse loop first (pure node-tested `reverseStep`); after operator
eyeball found reverse jittery (base clips not all-intra), narrowed to 0×–4×
forward and removed the reverse machinery. Verification: node unit + Playwright
a11y e2e + static-build e2e, all green per round. Project exempt from §9/PPE
(static Cloudflare).
## Deferred decisions
- **Direct FF-merge to `main` (no PR).** Operator explicitly said "Merge to
main," so both rounds landed via fast-forward (round 1 rebased over a
concurrent push) rather than opening a Gitea PR. Alternative: branch → PR →
merge (§5.4 norm). Operator-directed, so low risk.
- **Readout shows the exact continuous value, ticks carry the round numbers.**
The slider value is shown as e.g. `1.37×`; the "nice" increments (0.5 marks)
live as `<datalist>` ticks rather than snapping the value. My call within the
approved design; operator approved the spec.
- **Datalist tick spacing 0.5 over 04** (vs 0.25 over 2…2 in round 1). Cosmetic
choice to avoid clutter on the wider forward range. My call.
- **Reverse fully removed, not kept as a smaller range.** Operator said "just go
0x - 4x as the scale," so the `reverseStep` helper + tests were deleted rather
than parked. If reverse is wanted later, re-encode base loop clips all-intra
first (noted in memory + spec revision).
@@ -0,0 +1,68 @@
# Session 0039.0 — Transcript
> App: human-experience-filter-art
> Start: 2026-06-30T10-40 (PST)
> End: 2026-06-30T23-52 (PST)
> Type: planning-and-executing
> Posture: yolo
> Claude-Session: 9c986be3-c1f9-4123-bf7a-d62dd34810dd
> Checkout: worktree `welcome-screen-controls` (branch `worktree-welcome-screen-controls`, off origin/main)
**Date:** 2026-06-30
**Goal:** Build a pre-load "Welcome" screen for the simulator, then iterate it live per operator feedback.
**Outcome:** Shipped a large run of frontend work to production and **merged to `main` (`7ec8b60`, fast-forward)**. The project was renamed **Human/Machine Strata** and moved to **benstull.art/human-machine-strata/** (old URL 301-redirects). Live == main at close.
## Plan
Emergent, operator-driven (not a single up-front plan). The through-line:
1. Welcome screen: hide the right pane during load; put Video / Reduce-motion / Audio / Language controls on the pre-load screen; heads-up before "Loading Universe"; two states (welcome → centered loader); "Launch Simulator" entry.
2. Reconcile two `main` updates that landed mid-session (Playback Speed slider, 2..2 then 0×–4×).
3. Rework welcome controls → Language / Speed / Audio; drop the video toggle (video assumed on); re-add a Reduce-motion toggle that turns OFF morph transitions only (loop stays on Speed).
4. Publish to Cloudflare; then a stream of live tweaks: cap speed at 2×, cache hardening, panel reorder, rename, URL move.
5. Merge to `main` + final deploy.
## Pre-session state
- Simulator is a fully-static Cloudflare deployment (Pages + R2) — exempt from the Wiggleverse §9/flotilla pipeline; deploys straight to benstull.art. Was live at `/human-experience-simulator/`.
- Entry flow was: bare "Loading Universe" splash → fade → "Run simulation" button over a black stage → first-visit "Heads up" modal → begin (video on, audio→2). Controls (Video/Audio/Reduce-motion/Language) lived in the always-visible right panel.
- Parallel sessions were active on the shared checkout; this session ran in an isolated worktree per the isolation rule.
## Turn-by-turn arc
1. **Brainstorm + spec.** Explored the loading screen, right pane, controls, and i18n via an Explore agent. Wrote/committed the design spec (`05328b1`, `docs/superpowers/specs/2026-06-30-welcome-screen-design.md`). Operator refined it live (State B centered loader; "Launch Simulator" as the single entry button).
2. **Build the welcome screen** (`f077193`): folded `#loading` + `#motion-warning` + `#run-sim` into one `#welcome` overlay; State A (heads-up + controls + Launch, loader at bottom) → State B (Launch-while-loading: messaging gone, controls stay, loader centered) → auto-enter; panel hidden until entry via `body:has(#welcome)`; added `window.__hefReady` e2e seam; i18n `welcome.launch` + localized the previously en-only `warn.*`. Discovered + fixed a pre-existing i18n parity failure in passing. Node 35, e2e 27, static 2, pytest 301. Untracked stray `test-results/` artifacts (`7cdf594`).
3. **Reconcile main (twice).** Mid-session `main` gained a Playbook Speed slider (`df8b245`, 2..2) then narrowed it to 0×–4× forward, dropping reverse (`e064a2e`). Merged both into the branch; resolved conflicts in app.js/i18n/a11y; generalized the speed machinery to drive both the welcome and panel sliders.
4. **Welcome controls reworked** → Language / Speed / Audio (video assumed on at entry).
5. **Reduce-motion returns** (`5f9ae6b`). Operator: "we actually do need Reduced motion to turn off the morph videos." Speed only drives the resting loop, so re-added a visible Reduce-motion toggle (welcome + panel, default from OS pref) that makes altitude changes **instant cuts** (no morph tween) while the loop keeps following Speed. Decoupled the loop from reduce-motion (main had them coupled).
6. **Publish to benstull.art.** Verified wrangler auth, ran the frontend-only deploy; verified live + a headless smoke.
7. **Live tweaks (a stream):** "Video speed" rename (`79f64e3`); cap speed at 02× — >2× stutters, same all-intra reason reverse was dropped (`ccd4010`); panel: Experience knobs above Altitude, drop the dial hint + "(04)" (`fafb498`); page title + h1 → then renamed to **Human/Machine Strata** (`958c306`); move Altitude to the TOP of the panel for mobile (`c36f995`).
8. **Debugged "video + audio not playing on launch"** (systematic-debugging). Could not reproduce in Chromium headless, Chromium strict-autoplay, OR WebKit — everything played. Turned out to be a **stale Safari app-shell** (worked in a private window). Also found a stale uvicorn from another checkout squatting on :8099 serving old HTML, and `| tail` masking playwright's exit code — both wasted time.
9. **Cache hardening.** app-shell was `no-cache`; a per-HTML `no-store` override did NOT stick (Cloudflare Pages let `/seg/*` win), so switched the whole app path to `no-store` (`8c63b14`).
10. **Reduce-motion regression fixed** (`ecd444b`). Operator: "video doesn't change when a new altitude is selected with Reduce motion on." TDD: reproduced with a failing e2e, root-caused (the reduced-motion jump skips the segment that re-rolls the destination clip → `activeClipId` stayed on the old scale's clip), fixed with a self-heal at the `setPos` frac-0 landing (re-roll if the landed pool doesn't contain `activeClipId`).
11. **Rename + URL move.** Named the project **Human/Machine Strata**; moved the URL to `/human-machine-strata/` (`950c1e6`) with a permanent 301 from the legacy path (`LEGACY_APP_PATH`); the bare-root case needed its own rule (`b135cb5`). R2 bucket + Pages project names kept (not in the URL → no media re-upload). Updated the build test, static-build e2e path, and README paths.
12. **Merge + final deploy.** `main` had diverged by 2 commits (session-transcript files only) — merged origin/main in cleanly, then fast-forwarded `main` to the branch (`7ec8b60`). Deployed; verified new URL + old-URL 301.
## Cut state (end of session)
- **`main` = `origin/main` = branch `worktree-welcome-screen-controls` = `7ec8b60`.** Working tree clean; everything pushed and merged.
- **Live: https://benstull.art/human-machine-strata/** serves "Human/Machine Strata"; old `/human-experience-simulator/` (root + subpaths) 301-redirects; app-shell `Cache-Control: no-store`.
- Verification at close: node 35/35, Playwright dynamic e2e 33/33 (incl. new reduce-motion instant-cut clip test), static-build 2/2, pytest 301 passed (3 pre-existing ffmpeg/cv2 env-only failures, unrelated).
- Key commits (all on `main` via `7ec8b60`): `05328b1` spec · `f077193` welcome screen · reconcile merges of main's speed feature · `5f9ae6b` reduce-motion toggle · `ccd4010` 2× cap · `8c63b14` no-store · `fafb498` knobs-above-altitude · `ecd444b` reduce-motion clip re-roll · `c36f995` altitude-on-top · `958c306` rename · `950c1e6` URL move · `b135cb5` bare-root redirect · `7ec8b60` merge.
## What lands on the operator's plate
- **By-eye review on real Safari** of the shipped experience (headless can't exercise real autoplay/GPU; the reduce-motion instant-cut and 2× cap are logic/test-verified only).
- **Optional cleanup:** the dev-panel `#aud-test` element has a hardcoded `/media/...` src that 404s on the R2 site (dev-only, no user impact) — route it through `mediaBase()` if desired.
- **Possible follow-up:** "more welcome copy beyond the heads-up" (operator flagged early; the welcome-message block is structured to take more `data-i18n` paragraphs).
## Prompt the operator can paste into the next session
```
/goal Human/Machine Strata follow-ups. The welcome screen + rename + URL move are live and merged to main (benstull.art/human-machine-strata/). Next: (1) add the intro/framing welcome copy beyond the "Heads up" notice (welcome-message block is data-i18n-ready, 4 langs); (2) optionally clean up the dev-panel #aud-test hardcoded /media 404 by routing it through mediaBase(). Reminder env gotchas: only python3 on this box (start uvicorn by hand, __hefReady e2e seam replaces the old #loading wait); a stale uvicorn from another checkout can squat on :8099 (curl :8099 | grep welcome to confirm); don't trust `… | tail` exit codes.
```
## Deferred decisions
- Kept "Human/Machine Strata" as an **untranslated proper title** across all 4 languages (app.title), rather than localizing it — treated as the work's title. Low-confidence; easy to localize if the operator prefers.
- Renamed the app **URL path** but **not** the Pages project / R2 bucket names (they aren't in the benstull.art URL; renaming them would force a 2.3 GB media re-upload). Judged not worth it.
- Session started `careful`? No — no init ran; posture defaulted to `yolo` (planning-and-executing). All merges/deploys were operator-directed step by step.
+6
View File
@@ -109,5 +109,11 @@
},
"0037": {
"title": ""
},
"0038": {
"title": ""
},
"0039": {
"title": ""
}
}
+46 -10
View File
@@ -1,22 +1,26 @@
"""Production pass for the 5 per-altitude soundtracks (audio spec §5.3). 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"
@@ -30,6 +34,29 @@ SOURCES: dict[str, tuple[str, str]] = {
"abyss": ("whale.wav", "whale.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():
@@ -40,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()
+5 -5
View File
@@ -49,7 +49,7 @@ test.describe("Task 4 — Photosensitivity notice", () => {
});
test.describe("Task 5 — Playback speed slider + Reduce motion", () => {
test("Speed is a 04x forward slider; the loop follows it (default 1x)", async ({ page }) => {
test("Speed is a 02x forward slider; the loop follows it (default 1x)", async ({ page }) => {
await page.emulateMedia({ reducedMotion: "no-preference" });
await page.goto("/");
await enter(page);
@@ -57,17 +57,17 @@ test.describe("Task 5 — Playback speed slider + Reduce motion", () => {
await expect(slider).toBeVisible();
await expect(slider).toHaveValue("1"); // default = normal speed
await expect(slider).toHaveAttribute("min", "0");
await expect(slider).toHaveAttribute("max", "4");
await expect(slider).toHaveAttribute("max", "2"); // capped at 2x — base clips aren't all-intra, so >2x stutters
});
test("forward speed sets the loop video's playbackRate (up to 4x)", async ({ page }) => {
test("forward speed sets the loop video's playbackRate (up to 2x)", async ({ page }) => {
await page.emulateMedia({ reducedMotion: "no-preference" });
await page.goto("/");
await enter(page);
await page.locator("#play-speed").fill("4");
await page.locator("#play-speed").fill("2");
await expect
.poll(() => page.locator("#vid-loop").evaluate((v: HTMLVideoElement) => v.playbackRate))
.toBe(4);
.toBe(2);
});
test("0x freezes the loop video", async ({ page }) => {
+14
View File
@@ -108,6 +108,20 @@ test("dragging the dial scrubs morph currentTime and audio gains", async ({ page
await page.mouse.up();
});
test("reduce motion: selecting a new altitude still swaps to a fresh clip (instant cut)", async ({ page }) => {
await boot(page);
// #scale-name reads "<scale> · <clip> (i/n)"; extract the clip id.
const clipId = (s: string) => (s.split("·")[1] || "").split("(")[0].trim();
await page.locator('label[for="reduce-motion"]').click(); // engage reduce motion
await expect(page.locator("#reduce-motion")).toBeChecked();
const before = (await page.locator("#scale-name").textContent())!;
await wheelOnStage(page, 60); // descend one altitude (instant cut)
await page.waitForFunction(() => /orbit/.test(document.querySelector("#scale-name")?.textContent || ""), null, { timeout: 20000 });
const after = (await page.locator("#scale-name").textContent())!;
expect(after).toContain("orbit");
expect(clipId(after)).not.toBe(clipId(before)); // the clip actually changed (not stuck on the old scale's clip)
});
test("wheel auto-scrubs one altitude and locks", async ({ page }) => {
await boot(page);
const start = (await page.locator("#scale-name").textContent())!;
+76
View File
@@ -0,0 +1,76 @@
import { test, expect, Page } from "@playwright/test";
// The Audio-source feature (docs/superpowers/specs/2026-07-01-audio-music-option-design.md):
// the old 010 "Audio" slider becomes "Volume", and a new Audio <select> chooses the
// source — None / Soundtrack / Music. Music is a rotating playlist played the same at
// every altitude (no per-altitude crossfade).
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 });
}
async function selectSource(page: Page, v: string) {
await page.selectOption("#audio-source", v);
}
// The url the active <audio> element is loaded with (dataset.url is the raw path
// app.js assigns; set regardless of whether play() is allowed to start).
async function audUrls(page: Page): Promise<{ a: string; b: string }> {
return await page.evaluate(() => ({
a: (document.querySelector("#aud") as HTMLAudioElement).dataset.url || "",
b: (document.querySelector("#aud-b") as HTMLAudioElement).dataset.url || "",
}));
}
async function wheelOnStage(page: Page, deltaY: number) {
await page.locator("#stage").dispatchEvent("wheel", { deltaY });
}
test.describe("Audio source dropdown + Volume rename", () => {
test("the level slider is now labelled Volume and the Audio control is a 3-option dropdown", async ({ page }) => {
await enter(page);
await expect(page.locator('.panel label.audio-level span[data-i18n="output.volume"]')).toHaveText("Volume");
await expect(page.locator('.panel label.audio-source span[data-i18n="output.audio"]')).toHaveText("Audio");
const opts = page.locator("#audio-source option");
await expect(opts).toHaveCount(3);
await expect(opts).toHaveText(["None", "Soundtrack", "Music"]);
});
test("selecting None disables the Volume slider; a real source re-enables it", async ({ page }) => {
await enter(page);
await selectSource(page, "none");
await expect(page.locator("#audio")).toBeDisabled();
await selectSource(page, "soundtrack");
await expect(page.locator("#audio")).toBeEnabled();
});
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).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).toMatch(MUSIC_RE);
});
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("/audio/music/");
});
});
+56
View File
@@ -0,0 +1,56 @@
import { test, expect, Page } from "@playwright/test";
// Auto-advance moves to the next altitude when the altitude's loop VIDEO completes a
// playthrough (not on a fixed timer). Descends one step + wraps; a default-on toggle
// disables it; manual drag / a playing morph defer it. The e2e simulates a completed
// loop by dispatching the loop element's "ended" (the wrap-point and ended share the
// same advance path).
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")).toContainText("cosmos");
}
const state = (page: Page) => page.evaluate(() => (window as any).__hefState());
const idx = async (page: Page) => (await state(page)).ringIndex as number;
// Simulate the loop video finishing one playthrough.
async function completeLoopVideo(page: Page) {
await page.evaluate(() => {
const v = document.querySelector("#vid-loop") as HTMLVideoElement;
v.dataset.loopTail = "1"; // armed, as after a real landing
v.dispatchEvent(new Event("ended"));
});
}
test("a completed loop video advances one altitude (descending, wraps)", async ({ page }) => {
await boot(page);
const n = await page.evaluate(() => document.querySelectorAll(".dial-label").length);
const start = await idx(page);
await completeLoopVideo(page);
await page.waitForFunction((s) => (window as any).__hefState().ringIndex !== s, start, { timeout: 8000 });
expect(await idx(page)).toBe((start + 1) % n);
});
test("Auto-advance off: a completed video stays on the altitude", async ({ page }) => {
await boot(page);
await page.evaluate(() => { (document.querySelector("#auto-advance") as HTMLInputElement).checked = false; });
const start = await idx(page);
await completeLoopVideo(page);
await page.waitForTimeout(1500);
expect(await idx(page)).toBe(start);
});
test("keeps touring: a second completed video advances again", async ({ page }) => {
await boot(page);
const n = await page.evaluate(() => document.querySelectorAll(".dial-label").length);
const start = await idx(page);
await completeLoopVideo(page);
// wait until it has advanced AND settled (pos back on an integer detent)
await page.waitForFunction((s) => { const st = (window as any).__hefState(); return st.ringIndex !== s && Number.isInteger(st.pos); }, start, { timeout: 8000 });
await completeLoopVideo(page);
await page.waitForFunction((a) => (window as any).__hefState().ringIndex === (a.start + 2) % a.n, { start, n }, { timeout: 8000 });
expect(await idx(page)).toBe((start + 2) % n);
});
@@ -4,12 +4,16 @@ import { test, expect } from "@playwright/test";
// offset via a narrow timeupdate window. If that window is ever missed the clip
// fires `ended` and — without a safety net — freezes on its last frame ("videos
// aren't looping"). This asserts the `ended` safety net recovers playback.
// (With Auto-advance ON — the default — a completed video instead moves to the next
// altitude; that path is covered by auto-advance.spec.ts. Here we disable it to test
// the loop-back recovery in isolation.)
test("loop recovers when a clip reaches `ended` (no freeze on last frame)", async ({ page }) => {
await page.goto("/");
// Enter via the welcome screen (Video defaults on → the loop video loads).
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 page.evaluate(() => { const c = document.querySelector("#auto-advance") as HTMLInputElement; if (c) c.checked = false; });
// Wait for the loop video to actually have media before forcing the stuck state.
await page.waitForFunction(
() => { const v = document.getElementById("vid-loop") as HTMLVideoElement; return v && v.duration > 0; },
+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);
});
@@ -0,0 +1,63 @@
import { test, expect, Page } from "@playwright/test";
// Music is a shuffle: each track plays to its END, then a random OTHER track plays
// (never an immediate repeat), gaplessly, for as long as Music is selected. The e2e
// injects a short crossfade via window.__hefMusicXfadeMs and simulates a track finishing
// by dispatching its "ended" event (real tracks are minutes long).
const MUSIC_RE = /audio\/music\/(manatees|dewdrop|eastminster|overheat|concentration)\.mp3/;
async function boot(page: Page, xfadeMs = 300) {
await page.addInitScript((x) => { (window as any).__hefMusicXfadeMs = x; }, 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 });
}
// id ("aud"/"aud-b") of the louder (active) music element, and the track it holds.
async function active(page: Page): Promise<{ id: string; url: 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 { id: el.id, url: el.dataset.url || "" };
});
}
// Simulate the active track finishing.
async function endActive(page: Page, id: string) {
await page.evaluate((i) => (document.querySelector("#" + i) as HTMLAudioElement).dispatchEvent(new Event("ended")), id);
}
test("when the current track ends, a new RANDOM track plays", async ({ page }) => {
await boot(page);
await page.selectOption("#audio-source", "music");
await page.waitForTimeout(200);
const first = await active(page);
expect(first.url).toMatch(MUSIC_RE);
await endActive(page, first.id);
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 || "").includes("/audio/music/") && (el.dataset.url || "") !== f;
}, first.url, { timeout: 6000 });
const second = await active(page);
expect(second.url).toMatch(MUSIC_RE);
expect(second.url).not.toBe(first.url); // never an immediate repeat
});
test("Music keeps playing across several track-ends", async ({ page }) => {
await boot(page);
await page.selectOption("#audio-source", "music");
const seen = new Set<string>();
for (let i = 0; i < 3; i++) {
await page.waitForTimeout(400);
const cur = await active(page);
seen.add(cur.url);
await endActive(page, cur.id);
await page.waitForTimeout(400); // let the crossfade settle
}
expect([...seen].every((u) => MUSIC_RE.test(u))).toBe(true);
expect(seen.size).toBeGreaterThan(1); // it moved through multiple tracks
});
+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);
});
+1 -1
View File
@@ -3,7 +3,7 @@ import { test, expect, Page } from "@playwright/test";
// Runs against the BUILT static site: dist/ served on :8077, media (CORS) on :8078
// by serve-static.mjs. Build with `--media-base http://localhost:8078/` so config.js
// points media cross-origin — the production CORS path, locally. See README.md.
const APP = "http://localhost:8077/human-experience-simulator/";
const APP = "http://localhost:8077/human-machine-strata/";
// Enter the simulation via the welcome screen (Video defaults on → media plays).
async function enter(page: Page) {
+47
View File
@@ -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");
});
+43
View File
@@ -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");
});
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+2 -2
View File
@@ -3,7 +3,7 @@
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>About — Human Experience Filter</title>
<title>About — Human/Machine Strata</title>
<link rel="stylesheet" href="style.css" />
</head>
<body class="credits-page">
@@ -15,7 +15,7 @@
<section>
<h2>For everyone</h2>
<p>The <em>Human Experience Filter</em> is built to be accessible to — and
<p><em>Human/Machine Strata</em> is built to be accessible to — and
representative of — the full range of human experience. That is why it works
with a keyboard, with a screen reader, with reduced motion, and away from any
single screen or kiosk: an experience about being human should be open to as
+198 -26
View File
@@ -328,6 +328,7 @@ function ensureClipMedia() {
// tail. The morph element (#vid) is driven directly by the scrub.
loopVid.addEventListener("timeupdate", () => {
if (loopVid.dataset.loopTail === "1" && loopVid.duration && loopVid.currentTime >= loopVid.duration - 0.08) {
if (autoAdvanceOnLoopComplete()) return; // completed a playthrough → next altitude
try { loopVid.currentTime = loopStartFrame(); } catch (e) { /* not seekable */ }
}
});
@@ -339,6 +340,7 @@ loopVid.addEventListener("timeupdate", () => {
// rarely hits this; loaded real machines do.
loopVid.addEventListener("ended", () => {
if (loopVid.dataset.loopTail !== "1") return;
if (autoAdvanceOnLoopComplete()) return; // completed a playthrough → next altitude
try { loopVid.currentTime = loopStartFrame(); } catch (e) { /* not seekable */ }
loopVid.play().catch(() => {});
});
@@ -451,11 +453,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.
@@ -855,6 +859,20 @@ function onWheel(e) {
}, 90);
}
// --- Auto-advance: when the altitude's base loop VIDEO completes one playthrough, drift
// one altitude deeper (descend; past the deepest it wraps back to the top) — a slow kiosk
// tour paced by the footage itself. A default-on toggle disables it; a manual drag or a
// playing morph defers the move (it happens on the NEXT completion instead). Hooked into
// the loop element's completion points (the timeupdate wrap + the ended safety net).
function autoAdvanceOn() { const el = $("auto-advance"); return el ? el.checked : true; }
function autoAdvanceOnLoopComplete() {
if (autoAdvanceOn() && videoEverOn && !dialDrag && !busy) {
autoScrub(Math.round(pos) + 1); // completed a playthrough → next altitude
return true; // advanced — caller skips the normal loop-back
}
return false;
}
// --- 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 +1001,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;
@@ -1015,6 +1036,12 @@ function setPos(next) {
const { lo, frac } = HEFScrub.segmentOf(pos);
if (frac === 0) { // settled exactly on an altitude → lock + base loop + single soundtrack
busy = false;
// If we JUMPED straight here (a reduced-motion instant cut skips the segment that
// would re-roll the destination), activeClipId still points at the previous
// altitude's clip. Re-roll from this altitude's pool so the video actually changes.
if (!poolIds(ring.scales[ringIndex]).includes(activeClipId)) {
activeClipId = pickPoolClip(ringIndex, activeClipId);
}
delete vid.dataset.morph; // clear so re-entering the same segment reloads the morph (not the base)
currentClipId = null; // force ensureClipMedia to reload + loop the locked clip
// Land the loop on the frame the morph last showed for THIS travel direction:
@@ -1363,6 +1390,40 @@ function updateAudioLevelLabel() {
if (el) el.textContent = String(audioLevel());
}
// The Audio dropdown source: "none" | "soundtrack" | "music". `restAudio`/
// `blendAudio` consult HEFScrub.audioPlan(source, level, frac) for the mode branch.
function audioSource() {
const el = $("audio-source");
return el ? el.value : "soundtrack";
}
// Volume is meaningless when there is no source — grey it out on None.
function syncVolumeDisabled() {
const slider = $("audio");
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 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)) {
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).
@@ -1371,12 +1432,14 @@ function activeAudEl() { return audB.volume > aud.volume ? audB : aud; }
// --- Live audio status readout (diagnostic) — so a "no sound" report is legible ---
const AUD_ERR = ["", "aborted", "network", "decode", "format-not-supported"];
function audioStatusText() {
if (audioLevel() === 0) return "audio: off (level 0)";
const src = audioSource();
if (src === "none") return "audio: off (source None)";
if (audioLevel() === 0) return "audio: off (volume 0)";
const s = ring && ring.scales[ringIndex];
const url = soundtrackUrl();
const url = currentAudioUrl();
const el = activeAudEl();
const head = `L${audioLevel()}/10 · scale=${s ? s.id : "?"} · ring=${serverRing ? "server" : "SYNTH"} · url=${url || "NONE"}`;
if (!url) return "audio: " + head + " ← no soundtrack URL for this scale";
const head = `${src} · L${audioLevel()}/10 · scale=${s ? s.id : "?"} · ring=${serverRing ? "server" : "SYNTH"} · url=${url || "NONE"}`;
if (!url) return "audio: " + head + " ← no URL for this source/scale";
if (el.error) return `audio: ${head} · MEDIA ERROR ${el.error.code} (${AUD_ERR[el.error.code] || "?"})`;
if (audLastErr) return `audio: ${head} · ${audLastErr}`;
if (el.paused) return `audio: ${head} · PAUSED rs=${el.readyState}`;
@@ -1387,14 +1450,15 @@ function updateAudioStatus() {
if (!el) return;
el.textContent = audioStatusText();
const a = activeAudEl();
const on = audioLevel() > 0;
const playing = on && !!soundtrackUrl() && !a.paused && !a.error && !audLastErr;
const on = audioSource() !== "none" && audioLevel() > 0;
const url = currentAudioUrl();
const playing = on && !!url && !a.paused && !a.error && !audLastErr;
el.classList.toggle("playing", playing);
el.classList.toggle("blocked", on && (!soundtrackUrl() || !!a.error || !!audLastErr));
el.classList.toggle("blocked", on && (!url || !!a.error || !!audLastErr));
}
aud.addEventListener("error", updateAudioStatus);
audB.addEventListener("error", updateAudioStatus);
setInterval(updateAudioStatus, 400);
setInterval(() => { updateAudioStatus(); musicWatchdog(); }, 400);
// Cancel any in-flight fade on an element. A fade is a setInterval ramping volume;
// if a stale one keeps running after a new fade or a direct volume set, it drags the
@@ -1449,6 +1513,31 @@ function scaleAudioUrl(index) {
// The current resting altitude's soundtrack url (kept for the status readout).
function soundtrackUrl() { return scaleAudioUrl(ringIndex); }
// --- 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. Each track plays to its END, then a random OTHER one plays (never an
// immediate repeat); a short crossfade a few seconds before the end keeps it GAPLESS, so
// Music is always playing while selected. Reuses the two audio elements. The crossfade
// duration is injectable for tests.
const MUSIC_PLAYLIST = [
"music/manatees.mp3", "music/dewdrop.mp3", "music/eastminster.mp3",
"music/overheat.mp3", "music/concentration.mp3",
];
const MUSIC_XFADE_MS = (typeof window !== "undefined" && window.__hefMusicXfadeMs) || 6000;
const MUSIC_XFADE_S = MUSIC_XFADE_MS / 1000;
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 musicXfading = false; // true during a track→track crossfade (guards re-entry)
// The url the active source would play right now (for the diagnostic readout).
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.
// Only (re)loads `src` when the url actually changes (so a same-url call is a no-op
@@ -1480,31 +1569,97 @@ function assignElements(urlLo, urlHi) {
return { elLo: aud, elHi: audB };
}
// Mid-segment: crossfade scale `loIndex` (gain 1-frac) against loIndex+1 (gain frac),
// both scaled by the master audio level. No-op when the level is 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());
}
// Crossfade the finishing track out and a random OTHER track in on the other element.
function advanceMusic() {
if (audioSource() !== "music" || audioVol() === 0 || musicIdx < 0) return;
musicXfading = true;
const next = HEFScrub.pickNextIndex(musicIdx, MUSIC_PLAYLIST.length, Math.random());
const nextEl = musicEl === aud ? audB : aud;
const prevEl = musicEl;
ensurePlaying(nextEl, musicTrackUrl(next), 0); // incoming track, silent...
fadeVolume(nextEl, audioVol(), MUSIC_XFADE_MS, () => { musicXfading = false; }); // ...fade it in,
fadeVolume(prevEl, 0, MUSIC_XFADE_MS, () => prevEl.pause()); // ...fade the finishing one out.
musicEl = nextEl; musicIdx = next;
updateAudioStatus();
}
// timeupdate: a few seconds before the active track ends, crossfade to a random next
// (gapless). Only the active element, in Music mode, once per track.
function onMusicProgress(e) {
if (musicXfading || audioSource() !== "music" || musicIdx < 0 || e.target !== musicEl) return;
const d = musicEl.duration;
if (d && isFinite(d) && d > MUSIC_XFADE_S * 2 && musicEl.currentTime >= d - MUSIC_XFADE_S) advanceMusic();
}
// ended: safety net — a track that finishes without a crossfade (short track / missed
// timeupdate) immediately rolls a new random one, so Music never stops while selected.
function onMusicEnded(e) {
if (audioSource() !== "music" || musicIdx < 0 || e.target !== musicEl) return;
musicXfading = false;
advanceMusic();
}
// Start playback (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 (later crossfades play the 2nd element outside a gesture).
function startMusic(vol) {
if (musicIdx < 0) {
musicXfading = false;
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)
} else {
cancelFade(musicEl);
musicEl.volume = HEFScrub.clamp01(vol);
}
updateAudioStatus();
}
// Reset (on leaving Music, or going silent).
function stopMusic() { musicXfading = false; musicIdx = -1; musicEl = null; }
// Keep Music playing while selected: resume the active track if it stalls/pauses unexpectedly.
function musicWatchdog() {
if (audioSource() !== "music" || audioVol() === 0 || musicIdx < 0 || !musicEl) return;
if (musicEl.paused && !musicXfading) { const pr = musicEl.play(); if (pr) pr.catch(() => {}); }
}
for (const el of [aud, audB]) {
el.addEventListener("timeupdate", onMusicProgress);
el.addEventListener("ended", onMusicEnded);
}
// 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.
// Silent (None / Volume 0): leave the elements be — restAudio fades them on landing.
function blendAudio(loIndex, frac) {
const lvl = audioVol();
if (lvl === 0) return;
const plan = HEFScrub.audioPlan(audioSource(), audioVol(), frac);
if (plan.mode === "silent") 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 g = HEFScrub.crossfadeGains(frac);
const { elLo, elHi } = assignElements(urlLo, urlHi);
ensurePlaying(elLo, urlLo, g.from * lvl);
ensurePlaying(elHi, urlHi, g.to * lvl);
ensurePlaying(elLo, urlLo, plan.lo);
ensurePlaying(elHi, urlHi, plan.hi);
updateAudioStatus();
}
// At rest on a single altitude: the element holding it plays at the master level, the
// other fades out. Level 0 → both fade to silence.
// At rest on a single altitude. Silent → both fade out. Music → the single loop at
// the master volume. Soundtrack → the element holding this altitude plays at the
// master volume (frac 0 → lo = level), the other fades out.
function restAudio(index) {
if (audioLevel() === 0) {
fadeVolume(aud, 0, FADE_MS, () => aud.pause());
fadeVolume(audB, 0, FADE_MS, () => audB.pause());
return;
}
const plan = HEFScrub.audioPlan(audioSource(), audioVol(), 0);
if (plan.mode === "silent") { silenceAll(); 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;
ensurePlaying(active, url, audioVol());
ensurePlaying(active, url, plan.lo);
fadeVolume(idle, 0, FADE_MS);
updateAudioStatus();
}
@@ -1542,11 +1697,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() {
@@ -1579,6 +1741,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);
}
@@ -1602,6 +1767,13 @@ async function main() {
// play() outside a user gesture).
$("audio").addEventListener("input", () => { updateAudioLevelLabel(); applyAudio(); debounced(); });
updateAudioLevelLabel();
// 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();
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
// first, so videoEverOn is already true and this is a no-op beyond re-render.)
+2 -2
View File
@@ -3,12 +3,12 @@
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>HEF — Label Author Mode</title>
<title>Human/Machine Strata — Label Author Mode</title>
<link rel="stylesheet" href="/style.css" />
<link rel="stylesheet" href="/author.css" />
</head>
<body class="author">
<header><h1>HEF — Label Author Mode</h1>
<header><h1>Human/Machine Strata — Label Author Mode</h1>
<p class="hint">Scrub a pool clip · drag a box on a subject · run the tracker · author the tiers · save to the manifest. Geometry is propagated; <strong>semantics are hand-authored</strong> (content-pipeline §11.5). <a href="/">← back to preview</a></p>
</header>
<main>
+8 -3
View File
@@ -3,7 +3,7 @@
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Credits — Human Experience Filter</title>
<title>Credits — Human/Machine Strata</title>
<link rel="stylesheet" href="style.css" />
</head>
<body class="credits-page">
@@ -19,7 +19,7 @@
<section class="declaration">
<h2>License &amp; reuse</h2>
<p>
The <em>Human Experience Filter</em> alters its source footage in real time
<em>Human/Machine Strata</em> alters its source footage in real time
(overlays, an emotion/affect channel, and a WebGL “dream” shader), so every
frame you see is a <strong>derivative</strong> of the original clip.
</p>
@@ -48,13 +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 &amp; 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 &amp; gull calls (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>
+8 -4
View File
@@ -17,21 +17,25 @@
// Visitor-facing control chrome. Keys match `data-i18n` attributes in index.html.
const UI_STRINGS = {
"app.title": { en: "Human Experience Filter — Alteration Preview", es: "Filtro de Experiencia Humana — Vista previa de alteración", fr: "Filtre dExpérience Humaine — Aperçu daltération", ja: "ヒューマン・エクスペリエンス・フィルター — 変容プレビュー" },
"app.title": { en: "Human/Machine Strata", es: "Human/Machine Strata", fr: "Human/Machine Strata", ja: "Human/Machine Strata" },
"loading.title": { en: "Loading Universe", es: "Cargando el universo", fr: "Chargement de lunivers", ja: "宇宙を読み込み中" },
"welcome.launch": { en: "Launch Simulator", es: "Iniciar simulador", fr: "Lancer le simulateur", ja: "シミュレーターを起動" },
"credits.link": { en: "ⓘ Credits & licenses", es: "ⓘ Créditos y licencias", fr: "ⓘ Crédits et licences", ja: "ⓘ クレジットとライセンス" },
"output.legend": { en: "Output", es: "Salida", fr: "Sortie", ja: "出力" },
"output.video": { en: "Video", es: "Vídeo", fr: "Vidéo", ja: "映像" },
"output.audio": { en: "Audio", es: "Audio", fr: "Audio", ja: "音声" },
"output.audio.none": { en: "None", es: "Ninguno", fr: "Aucun", ja: "なし" },
"output.audio.soundtrack": { en: "Soundtrack", es: "Banda sonora", fr: "Bande-son", ja: "サウンドトラック" },
"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.hint": { en: "Turn the knob (drag it, or scroll) to change altitude — endless: past the deepest it wraps back up to the highest. Click a label to jump there.", es: "Gira el dial (arrástralo o desplázate) para cambiar la altitud — sin fin: tras lo más profundo vuelve a lo más alto. Haz clic en una etiqueta para saltar.", fr: "Tournez le cadran (glissez ou faites défiler) pour changer daltitude — sans fin : après le plus profond, on revient au plus haut. Cliquez sur une étiquette pour y aller.", ja: "ダイヤルを回して(ドラッグまたはスクロール)高度を変えます — 無限ループ:最も深い先は最も高い所へ戻ります。ラベルをクリックでそこへ移動" },
"knobs.legend": { en: "Experience knobs (04)", es: "Mandos de experiencia (04)", fr: "Boutons dexpérience (04)", ja: "体験つまみ0〜4" },
"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: "感じる" },
"knobs.mood": { en: "Mood — dark ◀ 0 ▶ light", es: "Ánimo — oscuro ◀ 0 ▶ claro", fr: "Humeur — sombre ◀ 0 ▶ clair", ja: "ムード — 暗 ◀ 0 ▶ 明" },
"devmode.label": { en: "Dev Mode", es: "Modo desarrollo", fr: "Mode dév", ja: "開発モード" },
"speed.label": { en: "Speed", es: "Velocidad", fr: "Vitesse", ja: "再生速度" },
"speed.label": { en: "Video speed", es: "Velocidad de vídeo", fr: "Vitesse vidéo", ja: "映像速度" },
"rm.label": { en: "Reduce motion", es: "Reducir movimiento", fr: "Réduire les animations", ja: "動きを減らす" },
"warn.title": { en: "Heads up — motion & flashing", es: "Atención — movimiento y destellos", fr: "Attention — mouvement et flashs", ja: "ご注意 — 動きと点滅" },
"warn.body": { en: "This experience contains continuous motion, flashing, and shifting imagery. If you are sensitive to motion or flashing light, turn on “Reduce motion” below before you launch — altitude changes will cut instantly instead of animating.", es: "Esta experiencia contiene movimiento continuo, destellos e imágenes cambiantes. Si eres sensible al movimiento o a las luces parpadeantes, activa «Reducir movimiento» abajo antes de iniciar — los cambios de altitud serán cortes instantáneos en vez de animarse.", fr: "Cette expérience contient des mouvements continus, des flashs et des images changeantes. Si vous êtes sensible au mouvement ou aux lumières clignotantes, activez « Réduire les animations » ci-dessous avant de lancer — les changements daltitude seront des coupes instantanées au lieu de sanimer.", ja: "この体験には連続した動き、点滅、変化する映像が含まれます。動きや点滅する光に敏感な方は、起動する前に下の「動きを減らす」をオンにしてください — 高度の変化はアニメーションせず瞬時に切り替わります。" },
+41 -23
View File
@@ -3,7 +3,7 @@
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>HEF — Alteration Simulator</title>
<title>Human/Machine Strata</title>
<link rel="stylesheet" href="style.css" />
</head>
<body>
@@ -21,12 +21,19 @@
<label class="lang-pick">🌐
<select id="welcome-lang" aria-label="Language"></select>
</label>
<label class="audio-level play-speed"><span data-i18n="speed.label">Speed</span>
<input type="range" id="welcome-play-speed" min="0" max="4" step="any" value="1"
<label class="audio-level play-speed"><span data-i18n="speed.label">Video speed</span>
<input type="range" id="welcome-play-speed" min="0" max="2" step="any" value="1"
list="play-speed-ticks" aria-describedby="welcome-play-speed-val" />
<span id="welcome-play-speed-val">1.00×</span>
</label>
<label class="audio-level"><span data-i18n="output.audio">Audio</span>
<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
</label>
@@ -44,7 +51,7 @@
</div>
</div>
<header>
<h1 data-i18n="app.title">Human Experience Filter — Alteration Preview</h1>
<h1 data-i18n="app.title">Human/Machine Strata</h1>
<a href="about.html" class="credits-link" data-i18n="about.link">About</a>
<a href="credits.html" class="credits-link" data-i18n="credits.link">ⓘ Credits &amp; licenses</a>
</header>
@@ -65,6 +72,21 @@
<section class="panel">
<div id="sr-status" class="visually-hidden" role="status" aria-live="polite"></div>
<fieldset>
<legend data-i18n="altitude.legend">Altitude</legend>
<div class="dial-wrap">
<svg id="dial" viewBox="0 0 100 100" role="slider" tabindex="0"
aria-label="Altitude — turn or use arrow keys to change scale"
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>
<legend data-i18n="output.legend">Output</legend>
<label class="dev-switch" for="visual">
@@ -72,19 +94,26 @@
<span class="dev-switch-track"><span class="dev-switch-thumb"></span></span>
<span class="dev-switch-label" data-i18n="output.video">Video</span>
</label>
<label class="audio-level"><span data-i18n="output.audio">Audio</span>
<label class="audio-source"><span data-i18n="output.audio">Audio</span>
<select id="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="audio" min="0" max="10" value="0" step="1" />
<span id="audio-level-val">0</span>/10
</label>
<label class="audio-level play-speed"><span data-i18n="speed.label">Speed</span>
<input type="range" id="play-speed" min="0" max="4" step="any" value="1"
<label class="audio-level play-speed"><span data-i18n="speed.label">Video speed</span>
<input type="range" id="play-speed" min="0" max="2" step="any" value="1"
list="play-speed-ticks" aria-describedby="play-speed-val" />
<span id="play-speed-val">1.00×</span>
</label>
<datalist id="play-speed-ticks">
<option value="0"></option><option value="0.5"></option><option value="1"></option>
<option value="1.5"></option><option value="2"></option><option value="2.5"></option>
<option value="3"></option><option value="3.5"></option><option value="4"></option>
<option value="0"></option><option value="0.25"></option><option value="0.5"></option>
<option value="0.75"></option><option value="1"></option><option value="1.25"></option>
<option value="1.5"></option><option value="1.75"></option><option value="2"></option>
</datalist>
<label class="dev-switch" for="reduce-motion">
<input type="checkbox" id="reduce-motion" />
@@ -97,18 +126,7 @@
</fieldset>
<fieldset>
<legend data-i18n="altitude.legend">Altitude</legend>
<div class="dial-wrap">
<svg id="dial" viewBox="0 0 100 100" role="slider" tabindex="0"
aria-label="Altitude — turn or use arrow keys to change scale"
aria-valuemin="0" aria-valuenow="0" aria-valuetext="cosmos"></svg>
</div>
<span id="scale-name" class="scale-name"></span>
<p class="hint" data-i18n="altitude.hint">Turn the knob (drag it, or scroll) to change altitude — endless: past the deepest it wraps back up to the highest. Click a label to jump there.</p>
</fieldset>
<fieldset>
<legend data-i18n="knobs.legend">Experience knobs (04)</legend>
<legend data-i18n="knobs.legend">Experience knobs</legend>
<label><span data-i18n="knobs.think">Think</span> <input type="range" id="left" min="0" max="4" value="0" /></label>
<label><span data-i18n="knobs.feel">Feel</span> <input type="range" id="right" min="0" max="4" value="0" /></label>
<label><span data-i18n="knobs.mood">Mood — dark ◀ 0 ▶ light</span> <input type="range" id="mood" min="-4" max="4" value="0" /></label>
+26 -1
View File
@@ -29,6 +29,31 @@
return { from: 1 - f, to: f };
}
// Pure audio-source routing: given the Audio dropdown source ("none" |
// "soundtrack" | "music"), the master volume `level` (0..1), and the altitude
// crossfade fraction `frac`, decide what the audio layer should do. app.js's
// restAudio/blendAudio consult this for the mode branch; the physical <audio>
// element assignment (which element carries which url) stays in app.js.
// - silent: play nothing (None, or level 0 — a hard mute in every mode)
// - music : ONE continuous track at the master level, altitude-independent
// - blend : the per-altitude soundtrack crossfade (lo = frac 0 scale, hi = next)
function audioPlan(source, level, frac) {
if (!(level > 0) || source === "none") return { mode: "silent" };
if (source === "music") return { mode: "music", vol: level };
const g = crossfadeGains(frac);
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
@@ -53,5 +78,5 @@
return out;
}
return { clamp01, wrapIndex, segmentOf, accumToPos, fracToTime, crossfadeGains, loopLandFrame, integerCrossings };
return { clamp01, wrapIndex, segmentOf, accumToPos, fracToTime, crossfadeGains, audioPlan, pickNextIndex, loopLandFrame, integerCrossings };
});
+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; }
+56
View File
@@ -0,0 +1,56 @@
"use strict";
const test = require("node:test");
const assert = require("node:assert/strict");
const S = require("../static/scrub.js");
// audioPlan decides, purely, what the audio layer should do for a given source
// mode + master level + crossfade fraction. app.js's restAudio/blendAudio consult
// it for the mode branch; the physical <audio> element assignment stays in app.js.
test("audioPlan: None source is silent regardless of level", () => {
assert.deepEqual(S.audioPlan("none", 1, 0.5), { mode: "silent" });
});
test("audioPlan: level 0 is silent even for soundtrack/music", () => {
assert.deepEqual(S.audioPlan("soundtrack", 0, 0.5), { mode: "silent" });
assert.deepEqual(S.audioPlan("music", 0, 0), { mode: "silent" });
});
test("audioPlan: Music plays a single track at the master level, no blend", () => {
assert.deepEqual(S.audioPlan("music", 0.8, 0.5), { mode: "music", vol: 0.8 });
});
test("audioPlan: Music ignores altitude fraction (constant across the dial)", () => {
const a = S.audioPlan("music", 0.6, 0);
const b = S.audioPlan("music", 0.6, 0.9);
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 });
const mid = S.audioPlan("soundtrack", 0.5, 0.5);
assert.equal(mid.mode, "blend");
assert.ok(Math.abs(mid.lo - 0.25) < 1e-9);
assert.ok(Math.abs(mid.hi - 0.25) < 1e-9);
});
+17 -5
View File
@@ -9,9 +9,9 @@ def test_build_emits_frontend_baked_json_and_only_referenced_media(tmp_path):
result = build_static(
out, media,
media_base="https://static.benstull.art/",
app_path="/human-experience-simulator",
app_path="/human-machine-strata",
)
app_dir = out / "human-experience-simulator"
app_dir = out / "human-machine-strata"
# frontend present under the app path; dev/author surfaces excluded
assert (app_dir / "index.html").exists()
@@ -25,10 +25,12 @@ def test_build_emits_frontend_baked_json_and_only_referenced_media(tmp_path):
assert not (app_dir / "author.html").exists()
assert not list(app_dir.glob("review*.html"))
# apex + no-slash redirect rules
# apex + no-slash redirect rules, plus the permanent legacy-path redirect
redirects = (out / "_redirects").read_text()
assert "/ /human-experience-simulator/ 308" in redirects
assert "/human-experience-simulator /human-experience-simulator/ 308" in redirects
assert "/ /human-machine-strata/ 308" in redirects
assert "/human-machine-strata /human-machine-strata/ 308" in redirects
assert "/human-experience-simulator/ /human-machine-strata/ 301" in redirects
assert "/human-experience-simulator/* /human-machine-strata/:splat 301" in redirects
# baked JSON matches the API shape
clips = json.loads((app_dir / "clips.json").read_text())
@@ -51,3 +53,13 @@ def test_build_emits_frontend_baked_json_and_only_referenced_media(tmp_path):
for f in versions["versions"]:
assert (media / f).exists(), f"missing synced media: {f}"
assert result["media_files"] == sum(1 for _ in media.rglob("*") if _.is_file())
def test_build_includes_the_music_playlist(tmp_path):
# The Music-option tracks are referenced by app.js (MUSIC_PLAYLIST), not the
# manifest — the build must copy them explicitly or Music 404s on the static site.
out = tmp_path / "dist"
media = tmp_path / "media"
build_static(out, media, media_base="https://static.benstull.art/", app_path="/human-machine-strata")
for name in ("manatees", "dewdrop", "eastminster", "overheat", "concentration"):
assert (media / "audio" / "music" / f"{name}.mp3").exists(), f"music track not synced: {name}"
+36 -11
View File
@@ -59,23 +59,44 @@ def _write_config(app_dir: Path, media_base: str) -> None:
)
# The app's original deploy path, kept as a permanent redirect so old links to
# /human-experience-simulator/… still land on the renamed /human-machine-strata/….
LEGACY_APP_PATH = "human-experience-simulator"
def _write_redirects(out: Path, seg: str) -> None:
# Apex → app path, and the no-trailing-slash form → slashed (so RELATIVE asset
# urls resolve under the prefix). Cloudflare Pages `_redirects` syntax.
(out / "_redirects").write_text(
f"/ /{seg}/ 308\n"
f"/{seg} /{seg}/ 308\n"
)
# urls resolve under the prefix). Plus a permanent (301) redirect from the legacy
# path to the current one. Cloudflare Pages `_redirects` syntax.
lines = [
f"/ /{seg}/ 308\n",
f"/{seg} /{seg}/ 308\n",
]
if LEGACY_APP_PATH and LEGACY_APP_PATH != seg:
# Exact root (trailing slash) first — the `/*` splat does NOT match the empty
# case, so the bare /legacy/ needs its own rule. Then the splat for subpaths,
# then the no-trailing-slash form.
lines.append(f"/{LEGACY_APP_PATH}/ /{seg}/ 301\n")
lines.append(f"/{LEGACY_APP_PATH}/* /{seg}/:splat 301\n")
lines.append(f"/{LEGACY_APP_PATH} /{seg}/ 301\n")
(out / "_redirects").write_text("".join(lines))
def _write_headers(out: Path, seg: str) -> None:
# The app shell (html/js/json) must NOT be browser-cached — otherwise a deploy is
# invisible to returning visitors for hours (Pages' default is max-age=14400).
# `no-cache` = store but revalidate every load (cheap 304 via etag). The media is
# on R2 (immutable + content-hash), unaffected. Cloudflare Pages `_headers` syntax.
# The app shell must NOT be browser-cached — otherwise a deploy is invisible to
# returning visitors for hours (Pages' default is max-age=14400). `no-store`
# (never stored → a guaranteed-fresh fetch every visit) for the whole app path:
# `no-cache` (revalidate) was NOT enough — Safari served a STALE index from its
# disk/bfcache, loaded the OLD versioned JS, and broke the page (black, silent).
# A per-HTML no-store override didn't stick (Pages let the `/seg/*` rule win), so
# we no-store the whole path. The app shell is tiny (~150 KB); the media is on R2
# (immutable + content-hash, a separate domain) and is unaffected. The content-
# hashed asset URLs (see _version_assets) remain as belt-and-suspenders for the
# case where Pages caches .js/.css at the edge by type and ignores _headers.
# Cloudflare Pages `_headers` syntax.
(out / "_headers").write_text(
f"/{seg}/*\n"
" Cache-Control: no-cache\n"
" Cache-Control: no-store\n"
)
@@ -130,6 +151,10 @@ def build_static(out_dir, media_out, *, media_base: str, app_path: str) -> dict:
for s in ring.get("scales", []):
if s.get("audio"):
referenced.add(f"audio/{s['audio']}")
# The Music-option playlist is referenced by the client (app.js MUSIC_PLAYLIST),
# not the manifest/ring — add it explicitly or Music 404s on the static site.
for name in ("manatees", "dewdrop", "eastminster", "overheat", "concentration"):
referenced.add(f"audio/music/{name}.mp3")
n = 0
for rel in sorted(referenced):
src = MEDIA_DIR / rel
@@ -151,7 +176,7 @@ if __name__ == "__main__":
ap.add_argument("--out", default="dist")
ap.add_argument("--media-out", default="dist-media")
ap.add_argument("--media-base", default="https://static.benstull.art/")
ap.add_argument("--app-path", default="/human-experience-simulator")
ap.add_argument("--app-path", default="/human-machine-strata")
a = ap.parse_args()
r = build_static(a.out, a.media_out, media_base=a.media_base, app_path=a.app_path)
print(json.dumps(r, indent=2))