Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 08cc7c6355 | |||
| 9d42ed7fc4 | |||
| 88ebb07578 | |||
| 2f8641cb28 | |||
| f6b45b6cd7 | |||
| d161db8110 | |||
| ee7190bb4a | |||
| 6816e4ce75 | |||
| 5226a1ebd5 | |||
| 6695f32b74 |
@@ -23,6 +23,7 @@ simulator/sample_media/**/*.m4a
|
||||
# Local-only candidate review galleries (re-buildable; not shipped content).
|
||||
simulator/static/review.html
|
||||
simulator/static/review_audio.html
|
||||
simulator/static/review_binaural.html
|
||||
|
||||
# Editor annotation/comment-thread metadata (not project content)
|
||||
.threads/
|
||||
|
||||
@@ -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,22 @@ recordings (NASA/NOAA) keep the "machines reshaping perception" thesis literal.
|
||||
| 🏖 coast | `coast/waves` | Sea waves + tern calls | BigSoundBank #0267 — https://bigsoundbank.com/sea-waves-and-seagulls-s0267.html (`/UPLOAD/mp3/0267.mp3`) | 57s | CC0 |
|
||||
| 🕳 abyss | `abyss/whale` | NE Pacific blue-whale "AB call" | NOAA PMEL — https://www.pmel.noaa.gov/acoustics/multimedia/NETS_bluWhale.wav | 13s | PD (NOAA) |
|
||||
|
||||
## Music (the Audio-dropdown "Music" option, shipped 2026-07-01)
|
||||
|
||||
**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 +85,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.
|
||||
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
# Binaural / entrainment candidate pool — stress-relief "Music" tracks (2026-07-02)
|
||||
|
||||
Candidates for evolving the **Music** audio option into a stress-relief set of
|
||||
**binaural-beat / brainwave-entrainment** tracks that **crossfade between each
|
||||
other** (not a single loop). Operator brief: "binaural beats for stress relief."
|
||||
|
||||
**All 11 below are CC0** (public domain — no attribution legally required; we credit
|
||||
anyway as courtesy), **all stereo**, all sourced from freesound.org and license-
|
||||
verified by fetching each page. Review gallery (gitignored, local-only, like
|
||||
`review_audio.html`): `simulator/static/review_binaural.html` → open
|
||||
`/review_binaural.html` with the sim server running. It streams each track's public
|
||||
HQ preview so you can audition + tick keepers.
|
||||
|
||||
## Caveats (operator, read before picking)
|
||||
|
||||
- **True binaural (#1–7) needs headphones.** The beat *is* the L/R frequency
|
||||
difference, so on speakers it collapses to a steady carrier tone (no entrainment).
|
||||
**#8–11** are ambient sub-bass / pulsing drones that keep the calm feel on speakers.
|
||||
- **Band coverage:** heavy on **delta (0.5–1.5 Hz)** + one clean **theta (4 Hz, #2)**.
|
||||
**No CC0 alpha (8–12 Hz)** track surfaced — widen to CC-BY (FMA/ccMixter) if wanted.
|
||||
- **No clean CC0/CC-BY isochronic or monaural** (the speaker-correct entrainment type)
|
||||
was found — every hit was NC/SA or unlicensed (see rejects). For speaker playback,
|
||||
lean on #8–11 for the "beat" feel.
|
||||
- **Previews vs originals:** the `cdn.freesound.org/…-hq.mp3` URLs are public streamable
|
||||
previews (verified live, HTTP 206). The **originals** (WAV/AIF/full-MP3) need a free
|
||||
freesound login; CC0 permits self-hosting, so for keepers download the originals and
|
||||
host on R2 — do not hot-link the CDN in production.
|
||||
|
||||
## Candidates
|
||||
|
||||
| # | id | title | artist | source page | license | dur | type | beat / carrier |
|
||||
|---|---|---|---|---|---|---|---|---|
|
||||
| 1 | `fs75309` | 60-64-delta | iantm | https://freesound.org/people/iantm/sounds/75309/ | CC0 | 0:30 | true-binaural | 4 Hz · 60/64 Hz |
|
||||
| 2 | `fs457654` | Binaural Beats (Theta Waves 4 Hz) | Fabrizio84 | https://freesound.org/people/Fabrizio84/sounds/457654/ | CC0 | 30:00 | true-binaural | 4 Hz theta |
|
||||
| 3 | `fs803742` | 1.23456789 Hz binaural beat (69L/70.23R) | itchytooth | https://freesound.org/people/itchytooth/sounds/803742/ | CC0 | 60:00 | true-binaural | 1.23 Hz delta · 69/70.23 Hz |
|
||||
| 4 | `fs414271` | Binaural Delta 440/440.5 Hz | pbabin | https://freesound.org/people/pbabin/sounds/414271/ | CC0 | 10:00 | true-binaural | 0.5 Hz · 440/440.5 Hz |
|
||||
| 5 | `fs414272` | Binaural Delta 500/501.5 Hz | pbabin | https://freesound.org/people/pbabin/sounds/414272/ | CC0 | 10:00 | true-binaural | 1.5 Hz · 500/501.5 Hz |
|
||||
| 6 | `fs414273` | Binaural Delta 52/52.5 Hz | pbabin | https://freesound.org/people/pbabin/sounds/414273/ | CC0 | 10:00 | true-binaural | 0.5 Hz · 52/52.5 Hz |
|
||||
| 7 | `fs414274` | Binaural Delta 60/61.5 Hz | pbabin | https://freesound.org/people/pbabin/sounds/414274/ | CC0 | 10:00 | true-binaural | 1.5 Hz · 60/61.5 Hz |
|
||||
| 8 | `fs856225` | Delta Pulse Subharmonic Drone (MANTICE) | bassimat | https://freesound.org/people/bassimat/sounds/856225/ | CC0 | 5:00 | ambient+beats (speaker-ok) | delta sub-bass (procedural) |
|
||||
| 9 | `fs854874` | Void Monolith Subharmonic Drone (MANTICE) | bassimat | https://freesound.org/people/bassimat/sounds/854874/ | CC0 | 5:00 | ambient (speaker-ok) | dark sub-drone |
|
||||
| 10 | `fs860122` | Expanding & Vibrating Stereo Pulsating Drone (MANTICE) | bassimat | https://freesound.org/people/bassimat/sounds/860122/ | CC0 | 2:00 | ambient+beats (speaker-ok) | slow stereo pulsation |
|
||||
| 11 | `fs493524` | Pulse Breath *(lower pref)* | phylobates | https://freesound.org/people/phylobates/sounds/493524/ | CC0 | 1:40 | ambient binaural | octaves of G / G♭ |
|
||||
|
||||
Preview MP3s (for the review page) follow `https://cdn.freesound.org/previews/<dir>/<id>_<uploader>-hq.mp3`
|
||||
— exact URLs are embedded in `review_binaural.html`.
|
||||
|
||||
## Rejected / could not verify
|
||||
|
||||
- **archive.org "Binaural Beats – Alpha/Beta/Theta/Delta"** & **"Isochronic Tones &
|
||||
Binaural Beats Combined"** (loopool / Jean-Paul Garnier) — **CC BY-NC-SA 3.0** (NC).
|
||||
The most promising isochronic set — rejected for the NC clause.
|
||||
- **archive.org "Om Alpha Brainwave Entrainment Loop"** (Entraino / KS Chauhan;
|
||||
freesound chauhans/100370) — **CC BY-NC 3.0**. Rejected (NC).
|
||||
- **archive.org "1hr 30 Min Theta Binaural Beat (7 Hz)"** (Chari G) — no license shown.
|
||||
Rejected (unverifiable).
|
||||
- **freesound "deadthetawaves.wav"** (NoiseCollector/43295) — license fine (**CC BY 3.0**)
|
||||
but "evil sounding loop of the living dead" — rejected for *fit*, not license. Back
|
||||
pocket only if an unsettling deep-scale track is ever wanted.
|
||||
- **TunePocket / dialedggsound / miraclefrequencies / audio.com "Theta 7Hz"** —
|
||||
"royalty-free"/commercial pages, murky redistribution, no per-track CC license.
|
||||
|
||||
## Next
|
||||
|
||||
Operator auditions `review_binaural.html`, ticks keepers (Copy picks). Then we wire the
|
||||
playback: the **Music** source becomes a multi-track set that crossfades between the kept
|
||||
tracks (architecture decision — replace vs. join the ethereal loop — deferred to that
|
||||
step; see [[audio-music-option]]).
|
||||
@@ -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
|
||||
0–10 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 0–10 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,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 0–2× — >2× stutters, same all-intra reason reverse was dropped (`ccd4010`); panel: Experience knobs above Altitude, drop the dial hint + "(0–4)" (`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.
|
||||
@@ -112,5 +112,8 @@
|
||||
},
|
||||
"0038": {
|
||||
"title": ""
|
||||
},
|
||||
"0039": {
|
||||
"title": ""
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
"""Production pass for the 5 per-altitude soundtracks (audio spec §5.3). The audio
|
||||
"""Production pass for the 5 per-altitude soundtracks + the single Music loop
|
||||
(audio spec §5.3; Music layer per docs/audio-candidate-pool.md). The audio
|
||||
analogue of build_pool_manifest.py --media: read the sourced ambience clips
|
||||
(re-downloadable per docs/audio-candidate-pool.md), and make each a seamless
|
||||
loudness-normalized loop.
|
||||
@@ -22,12 +23,18 @@ AUDIO = Path(__file__).parent / "sample_media" / "audio"
|
||||
|
||||
# scale -> (sourced raw file, production output file), both under audio/<scale>/.
|
||||
# Sources per docs/audio-candidate-pool.md (the "✓ KEEP" picks).
|
||||
#
|
||||
# The `music` entry is the Audio-dropdown "Music" layer (docs/audio-candidate-pool.md
|
||||
# § Music): ONE ethereal ambient loop played the same at every altitude — not a
|
||||
# per-altitude soundtrack. It lands at audio/music/ethereal.loop.mp3, served at
|
||||
# /media/audio/music/ethereal.loop.mp3 (client fallback: app.js MUSIC_FALLBACK).
|
||||
SOURCES: dict[str, tuple[str, str]] = {
|
||||
"cosmos": ("pillars.mp3", "pillars.loop.mp3"),
|
||||
"orbit": ("spaceamb.mp3", "spaceamb.loop.mp3"),
|
||||
"coast": ("waves.mp3", "waves.loop.mp3"),
|
||||
"reef": ("soundscape.mp3", "soundscape.loop.mp3"),
|
||||
"abyss": ("whale.wav", "whale.loop.mp3"),
|
||||
"music": ("ethereal.mp3", "ethereal.loop.mp3"),
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import { test, expect, Page } from "@playwright/test";
|
||||
|
||||
// The Audio-source feature (docs/superpowers/specs/2026-07-01-audio-music-option-design.md):
|
||||
// the old 0–10 "Audio" slider becomes "Volume", and a new Audio <select> chooses the
|
||||
// source — None / Soundtrack / Music. Music is ONE ethereal loop played the same at
|
||||
// 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();
|
||||
});
|
||||
|
||||
test("Music plays one ethereal loop that persists across an altitude change", async ({ page }) => {
|
||||
await enter(page);
|
||||
await selectSource(page, "music");
|
||||
let urls = await audUrls(page);
|
||||
expect(urls.a).toContain("music/ethereal.loop.mp3");
|
||||
// Move the dial: Music is altitude-independent, so #aud stays on the music loop
|
||||
// (it does NOT switch to a per-altitude soundtrack).
|
||||
await wheelOnStage(page, 120);
|
||||
await wheelOnStage(page, 120);
|
||||
await page.waitForTimeout(300);
|
||||
urls = await audUrls(page);
|
||||
expect(urls.a).toContain("music/ethereal.loop.mp3");
|
||||
});
|
||||
|
||||
test("Soundtrack loads a per-altitude ambience, not the music loop", async ({ page }) => {
|
||||
await enter(page);
|
||||
await selectSource(page, "soundtrack");
|
||||
await page.waitForTimeout(200);
|
||||
const urls = await audUrls(page);
|
||||
const loaded = urls.a + "|" + urls.b;
|
||||
expect(loaded).toContain("/audio/");
|
||||
expect(loaded).not.toContain("music/ethereal.loop.mp3");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
import { test, expect, Page } from "@playwright/test";
|
||||
|
||||
// Auto-advance: after the dial sits idle for the interval, the altitude drifts one
|
||||
// step deeper (descend), wrapping past the deepest. A default-on toggle disables it.
|
||||
// The e2e injects a short interval via window.__hefAutoAdvanceMs before load.
|
||||
|
||||
async function boot(page: Page, intervalMs: number) {
|
||||
await page.addInitScript((ms) => { (window as any).__hefAutoAdvanceMs = ms; }, intervalMs);
|
||||
await page.goto("/");
|
||||
await page.waitForFunction(() => (window as any).__hefReady === true, null, { timeout: 45_000 });
|
||||
await page.locator("#welcome-launch").click();
|
||||
await page.waitForSelector("#welcome", { state: "detached", timeout: 10_000 });
|
||||
}
|
||||
|
||||
const idx = (page: Page) => page.evaluate(() => (window as any).__hefState().ringIndex as number);
|
||||
|
||||
test("auto-advances one altitude (descending) after the idle interval", async ({ page }) => {
|
||||
await boot(page, 1200);
|
||||
const n = await page.evaluate(() => (window as any).__hefState && (document.querySelectorAll(".dial-label").length));
|
||||
const start = await idx(page);
|
||||
// Wait past the interval + the morph animation for the landing.
|
||||
await page.waitForFunction((s) => (window as any).__hefState().ringIndex !== s, start, { timeout: 8000 });
|
||||
const after = await idx(page);
|
||||
expect(after).toBe((start + 1) % n); // descended exactly one, wraps
|
||||
});
|
||||
|
||||
test("Auto-advance off keeps the altitude parked", async ({ page }) => {
|
||||
// Disable BEFORE launch so the idle timer never arms (deterministic — no race with
|
||||
// the first interval firing during boot).
|
||||
await page.addInitScript(() => { (window as any).__hefAutoAdvanceMs = 800; });
|
||||
await page.goto("/");
|
||||
await page.waitForFunction(() => (window as any).__hefReady === true, null, { timeout: 45_000 });
|
||||
await page.evaluate(() => { (document.querySelector("#auto-advance") as HTMLInputElement).checked = false; });
|
||||
await page.locator("#welcome-launch").click();
|
||||
await page.waitForSelector("#welcome", { state: "detached", timeout: 10_000 });
|
||||
|
||||
const start = await idx(page);
|
||||
await page.waitForTimeout(2200); // well past two 800ms intervals
|
||||
expect(await idx(page)).toBe(start); // never moved
|
||||
});
|
||||
|
||||
test("the idle countdown resets on interaction (no advance right after a manual turn)", async ({ page }) => {
|
||||
await boot(page, 1500);
|
||||
// Immediately before the first interval elapses, interact (keyboard step). That step
|
||||
// itself advances by 1; the reset then means no AUTO advance fires for another
|
||||
// full interval — so shortly after, we should still be at exactly that +1.
|
||||
await page.waitForTimeout(900);
|
||||
const before = await idx(page);
|
||||
await page.locator("#dial").focus();
|
||||
await page.locator("#dial").press("ArrowDown"); // manual descend + reset
|
||||
await page.waitForFunction((b) => (window as any).__hefState().ringIndex !== b, before, { timeout: 4000 });
|
||||
const manual = await idx(page);
|
||||
await page.waitForTimeout(900); // < the 1500ms interval
|
||||
expect(await idx(page)).toBe(manual); // countdown reset → no extra auto step yet
|
||||
});
|
||||
@@ -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,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);
|
||||
});
|
||||
Binary file not shown.
Binary file not shown.
+105
-30
@@ -451,11 +451,13 @@ function paintLoop() {
|
||||
if (paint.width !== w || paint.height !== h) { paint.width = w; paint.height = h; }
|
||||
gl.viewport(0, 0, w, h);
|
||||
try { gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGB, gl.RGB, gl.UNSIGNED_BYTE, src); } catch (_) {}
|
||||
// No painterly restyle during a ring transition (busy) — show it raw.
|
||||
// No painterly DREAM during a ring transition (busy) — show the morph crisp.
|
||||
// The Dark/Light mood GRADE, however, must ride through the transition too
|
||||
// (else the morph shows ungraded and the grade snaps back only on landing).
|
||||
gl.uniform1f(uAmt, busy ? 0 : dreamIntensity);
|
||||
gl.uniform2f(uTexel, 1 / w, 1 / h);
|
||||
gl.drawArrays(gl.TRIANGLES, 0, 3);
|
||||
paint.style.filter = busy ? "none" : gradeFilter;
|
||||
paint.style.filter = gradeFilter;
|
||||
}
|
||||
// Animate per-frame: re-place keyframed tracks AND re-evaluate time-windowed
|
||||
// labels (a label enters/leaves frame as the loop plays) against playback time.
|
||||
@@ -846,6 +848,7 @@ function autoScrub(targetPos, perAltMs = PER_ALTITUDE_MS) {
|
||||
let wheelAccum = 0, wheelTimer = null;
|
||||
function onWheel(e) {
|
||||
e.preventDefault();
|
||||
bumpAutoAdvance();
|
||||
wheelAccum += e.deltaY;
|
||||
clearTimeout(wheelTimer);
|
||||
wheelTimer = setTimeout(() => {
|
||||
@@ -855,6 +858,27 @@ function onWheel(e) {
|
||||
}, 90);
|
||||
}
|
||||
|
||||
// --- Auto-advance: after the dial sits idle for AUTO_ADVANCE_MS, drift one altitude
|
||||
// deeper (descend; past the deepest it wraps back to the top), a slow kiosk tour. Any
|
||||
// manual interaction restarts the idle countdown; a default-on toggle disables it.
|
||||
// (E2E injects a short interval via window.__hefAutoAdvanceMs before load.)
|
||||
const AUTO_ADVANCE_MS = (typeof window !== "undefined" && window.__hefAutoAdvanceMs) || 60000;
|
||||
let autoAdvanceTimer = null;
|
||||
function autoAdvanceOn() { const el = $("auto-advance"); return el ? el.checked : true; }
|
||||
function scheduleAutoAdvance() {
|
||||
clearTimeout(autoAdvanceTimer);
|
||||
autoAdvanceTimer = null;
|
||||
if (!autoAdvanceOn() || !videoEverOn) return; // only while the experience is running
|
||||
autoAdvanceTimer = setTimeout(onAutoAdvance, AUTO_ADVANCE_MS);
|
||||
}
|
||||
function onAutoAdvance() {
|
||||
// Skip a beat (but keep the cadence) if the visitor is mid-drag or a morph is playing.
|
||||
if (autoAdvanceOn() && videoEverOn && !dialDrag && !busy) autoScrub(Math.round(pos) + 1);
|
||||
scheduleAutoAdvance();
|
||||
}
|
||||
// Restart the idle countdown on any manual interaction (drag/wheel/key/knob).
|
||||
function bumpAutoAdvance() { if (videoEverOn) scheduleAutoAdvance(); }
|
||||
|
||||
// --- Altitude knob: the endless rotary encoder, drawn as a turnable dial ---
|
||||
// cosmos (highest) sits at the top; turning CLOCKWISE descends through the scales
|
||||
// (cosmos → orbit → coast → reef → abyss) and past the deepest WRAPS back to the
|
||||
@@ -983,7 +1007,10 @@ function rebuildSegment(lo, enteredFrom) {
|
||||
overlay.style.opacity = "0";
|
||||
affectLayer.style.opacity = "0";
|
||||
tint.style.opacity = "0";
|
||||
vid.style.filter = "none";
|
||||
// WebGL path: the graded canvas sits on top, so #vid stays raw (no double-grade).
|
||||
// Fallback path: #vid IS the visible surface, so it must carry the mood grade
|
||||
// through the morph (setting "none" here was the "morph ignores Mood" bug).
|
||||
vid.style.filter = paintOK ? "none" : gradeFilter;
|
||||
vid.loop = false;
|
||||
if (vid.dataset.morph !== file) {
|
||||
vid.dataset.morph = file;
|
||||
@@ -1059,6 +1086,7 @@ function setPos(next) {
|
||||
function onDialDown(e) {
|
||||
if (!ring || ring.scales.length < 2) return;
|
||||
e.preventDefault();
|
||||
bumpAutoAdvance();
|
||||
dialDrag = { lastAng: dialAngle(e), accum: 0, moved: 0, target: e.target, restPos: pos };
|
||||
}
|
||||
function onDialMove(e) {
|
||||
@@ -1085,6 +1113,7 @@ function onDialUp(e) {
|
||||
// short way to the CLOSEST one and settle there (frac 0 locks). The result is the
|
||||
// same as a tap/label-click: a grab-and-release always lands on a discrete altitude.
|
||||
autoScrub(Math.round(pos));
|
||||
bumpAutoAdvance();
|
||||
}
|
||||
|
||||
// Click a label → auto-scrub the SHORTEST signed way around the ring to that scale.
|
||||
@@ -1115,7 +1144,7 @@ function onDialKey(e) {
|
||||
case "End": jumpToScale(ring.scales.length - 1); break;
|
||||
default: handled = false;
|
||||
}
|
||||
if (handled) e.preventDefault();
|
||||
if (handled) { e.preventDefault(); bumpAutoAdvance(); }
|
||||
}
|
||||
|
||||
// --- Dev Mode: pool picker + live analysis, all under one toggle ---
|
||||
@@ -1369,6 +1398,18 @@ 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";
|
||||
}
|
||||
|
||||
// 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).
|
||||
@@ -1377,12 +1418,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}`;
|
||||
@@ -1393,10 +1436,11 @@ 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);
|
||||
@@ -1455,6 +1499,16 @@ function scaleAudioUrl(index) {
|
||||
// The current resting altitude's soundtrack url (kept for the status readout).
|
||||
function soundtrackUrl() { return scaleAudioUrl(ringIndex); }
|
||||
|
||||
// The single continuous "Music" loop — one ethereal ambient track, played the
|
||||
// same at every altitude. Fallback constant mirrors SCALE_AUDIO_FALLBACK so it
|
||||
// works even against a server predating the manifest's music entry; routed through
|
||||
// mediaBase() for R2 (static) / local /media (dev), like scaleAudioUrl.
|
||||
const MUSIC_FALLBACK = "music/ethereal.loop.mp3";
|
||||
function musicUrl() { return mediaBase() + "audio/" + MUSIC_FALLBACK; }
|
||||
|
||||
// The url the active source would play right now (for the diagnostic readout).
|
||||
function currentAudioUrl() { return audioSource() === "music" ? musicUrl() : 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
|
||||
@@ -1486,31 +1540,45 @@ 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.
|
||||
function blendAudio(loIndex, frac) {
|
||||
const lvl = audioVol();
|
||||
if (lvl === 0) return;
|
||||
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);
|
||||
// Both elements fade to silence (None, or Volume 0).
|
||||
function silenceAll() {
|
||||
fadeVolume(aud, 0, FADE_MS, () => aud.pause());
|
||||
fadeVolume(audB, 0, FADE_MS, () => audB.pause());
|
||||
}
|
||||
|
||||
// Play the single continuous Music loop on `aud` at `vol`; fade the other out. No
|
||||
// altitude crossfade — the same track plays the same at every altitude.
|
||||
function playMusic(vol) {
|
||||
ensurePlaying(aud, musicUrl(), vol);
|
||||
fadeVolume(audB, 0, FADE_MS);
|
||||
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.
|
||||
// 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 plan = HEFScrub.audioPlan(audioSource(), audioVol(), frac);
|
||||
if (plan.mode === "silent") return;
|
||||
if (plan.mode === "music") { playMusic(plan.vol); return; }
|
||||
const urlLo = scaleAudioUrl(loIndex), urlHi = scaleAudioUrl(loIndex + 1);
|
||||
const { elLo, elHi } = assignElements(urlLo, urlHi);
|
||||
ensurePlaying(elLo, urlLo, plan.lo);
|
||||
ensurePlaying(elHi, urlHi, plan.hi);
|
||||
updateAudioStatus();
|
||||
}
|
||||
|
||||
// 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") { playMusic(plan.vol); return; }
|
||||
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();
|
||||
}
|
||||
@@ -1538,6 +1606,7 @@ function beginExperience() {
|
||||
updateAudioLevelLabel();
|
||||
applyAudio();
|
||||
applyPlaySpeed();
|
||||
scheduleAutoAdvance(); // start the idle-drift countdown now that we're running
|
||||
}
|
||||
|
||||
// Welcome screen ----------------------------------------------------------------
|
||||
@@ -1602,12 +1671,18 @@ async function main() {
|
||||
initWelcome(); // wire the welcome-screen controls + Launch button
|
||||
renderScaleReadout();
|
||||
// Sliders stream on "input"; the toggles fire on "change".
|
||||
for (const id of ["left", "right", "mood"]) $(id).addEventListener("input", debounced);
|
||||
for (const id of ["left", "right", "mood"]) $(id).addEventListener("input", () => { bumpAutoAdvance(); debounced(); });
|
||||
// Auto-advance toggle: (re)start or cancel the idle-drift countdown on change.
|
||||
$("auto-advance").addEventListener("change", scheduleAutoAdvance);
|
||||
// Audio level dial: set the master soundtrack gain. Reconcile SYNCHRONOUSLY in this
|
||||
// input gesture so the first raise from 0 unlocks + plays on Safari (which blocks
|
||||
// play() outside a user gesture).
|
||||
$("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(); });
|
||||
syncVolumeDisabled();
|
||||
// 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.)
|
||||
|
||||
@@ -55,6 +55,7 @@
|
||||
<li><strong>NOAA</strong> SanctSound & PMEL hydrophone recordings — reef/abyss biological layers (public domain).</li>
|
||||
<li><strong>freesound.org</strong> contributors — Sonicfreak (station room-tone) and DCSFX (underwater bed) (CC0).</li>
|
||||
<li><strong>BigSoundBank</strong> — sea waves & gull calls (CC0).</li>
|
||||
<li><strong>deadrobotmusic</strong> (freesound.org) — “Ambient F Sharp Minor Ethereal Choir Pad” (the <em>Music</em> option) (CC0).</li>
|
||||
</ul>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
@@ -24,7 +24,12 @@
|
||||
"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.autoadvance": { en: "Auto-advance", es: "Avance automático", fr: "Avance automatique", ja: "自動で移動" },
|
||||
"knobs.legend": { en: "Experience knobs", es: "Mandos de experiencia", fr: "Boutons d’expérience", ja: "体験つまみ" },
|
||||
"knobs.think": { en: "Think", es: "Pensar", fr: "Penser", ja: "考える" },
|
||||
"knobs.feel": { en: "Feel", es: "Sentir", fr: "Ressentir", ja: "感じる" },
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
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-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>
|
||||
@@ -73,6 +73,11 @@
|
||||
aria-valuemin="0" aria-valuenow="0" aria-valuetext="cosmos"></svg>
|
||||
</div>
|
||||
<span id="scale-name" class="scale-name">—</span>
|
||||
<label class="dev-switch" for="auto-advance">
|
||||
<input type="checkbox" id="auto-advance" checked />
|
||||
<span class="dev-switch-track"><span class="dev-switch-thumb"></span></span>
|
||||
<span class="dev-switch-label" data-i18n="altitude.autoadvance">Auto-advance</span>
|
||||
</label>
|
||||
</fieldset>
|
||||
|
||||
<fieldset>
|
||||
@@ -82,7 +87,14 @@
|
||||
<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>
|
||||
|
||||
@@ -29,6 +29,21 @@
|
||||
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 };
|
||||
}
|
||||
|
||||
// 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 +68,5 @@
|
||||
return out;
|
||||
}
|
||||
|
||||
return { clamp01, wrapIndex, segmentOf, accumToPos, fracToTime, crossfadeGains, loopLandFrame, integerCrossings };
|
||||
return { clamp01, wrapIndex, segmentOf, accumToPos, fracToTime, crossfadeGains, audioPlan, loopLandFrame, integerCrossings };
|
||||
});
|
||||
|
||||
@@ -97,7 +97,7 @@ main { display: flex; gap: 1rem; padding: 1rem; flex-wrap: wrap; align-items: fl
|
||||
.audio-status { margin-top: 0.5rem; font: 11px/1.4 monospace; color: #9ab; }
|
||||
.audio-status.playing { color: #4e9; }
|
||||
.audio-status.blocked { color: #f97; }
|
||||
.panel { flex: 0 0 280px; display: flex; flex-direction: column; gap: 0.8rem;
|
||||
.panel { flex: 0 0 300px; display: flex; flex-direction: column; gap: 0.8rem;
|
||||
max-height: calc(100vh - 2rem); overflow-y: auto;
|
||||
position: sticky; top: 1rem; }
|
||||
/* The control panel stays hidden behind the welcome screen and appears only once
|
||||
@@ -151,7 +151,10 @@ input[type=range], select { width: 100%; }
|
||||
/* Audio level dial (0–10): label · slider · value on one row, matching the switch look. */
|
||||
.audio-level { display: flex; align-items: center; gap: 0.5rem; margin-top: 0.45rem;
|
||||
color: #9af; font-weight: 600; letter-spacing: 0.3px; }
|
||||
.audio-level input[type=range] { flex: 1; width: auto; }
|
||||
/* Keep the control label on one line ("Video speed" was wrapping); let the range
|
||||
shrink (min-width:0) instead of pushing the label to a second row. */
|
||||
.audio-level > span:first-child { white-space: nowrap; }
|
||||
.audio-level input[type=range] { flex: 1; width: auto; min-width: 0; }
|
||||
#audio-level-val { font-variant-numeric: tabular-nums; min-width: 1.2em; text-align: right; }
|
||||
/* Playback speed (−2×…2×) reuses the audio-level row look; wider value field for the sign + decimals. */
|
||||
#play-speed-val, #welcome-play-speed-val { font-variant-numeric: tabular-nums; min-width: 3.4em; text-align: right; }
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
"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("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);
|
||||
});
|
||||
Reference in New Issue
Block a user