Compare commits
23 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b6bf970684 | |||
| 34fb48cdec | |||
| c8416ef2e9 | |||
| e666465d62 | |||
| 8c6778230b | |||
| 89cd6aa1dd | |||
| 04c8855ddc | |||
| da3581afea | |||
| 4f4f21a767 | |||
| 969236b3f7 | |||
| b0bd784305 | |||
| 04c3a133c3 | |||
| 028d02a033 | |||
| 08cc7c6355 | |||
| 9d42ed7fc4 | |||
| 88ebb07578 | |||
| 2f8641cb28 | |||
| f6b45b6cd7 | |||
| d161db8110 | |||
| ee7190bb4a | |||
| 6816e4ce75 | |||
| 5226a1ebd5 | |||
| 6695f32b74 |
@@ -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/
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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 (#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`.
|
||||
|
||||
## Round 2 — musical / evolving (the "not boring" set, 2026-07-02)
|
||||
|
||||
Operator found the pure-tone set (#1–7) too plain. **Reality:** truly *musical* tracks
|
||||
with a **confirmed** binaural layer are almost all NC (see rejects). Best legitimate
|
||||
options — pick for how they *sound*; treat binaural as a bonus:
|
||||
|
||||
| id | title | artist | source | license | dur | binaural? |
|
||||
|---|---|---|---|---|---|---|
|
||||
| `fs856223` | Theta Gateway Sacred Drone (MANTICE) | bassimat | https://freesound.org/people/bassimat/sounds/856223/ | CC0 | 5:00 | capable (theta band; not certified on render) |
|
||||
| `fs856221` | Raga of Stillness Sacred Drone (MANTICE) | bassimat | https://freesound.org/people/bassimat/sounds/856221/ | CC0 | 5:00 | capable (Δ/θ/α/β optional) |
|
||||
| `fs856220` | Ice Cathedral Sacred Drone (MANTICE) | bassimat | https://freesound.org/people/bassimat/sounds/856220/ | CC0 | 5:00 | capable |
|
||||
| `fs856224` | Vowel of the Deep Sacred Drone (MANTICE) | bassimat | https://freesound.org/people/bassimat/sounds/856224/ | CC0 | 5:00 | capable |
|
||||
| `fs856222` | Stellar Vowel Sacred Drone (MANTICE) | bassimat | https://freesound.org/people/bassimat/sounds/856222/ | CC0 | 5:00 | capable |
|
||||
| `fs856329` | Harmonic Chamber High-Frequency Drone (MANTICE) | bassimat | https://freesound.org/people/bassimat/sounds/856329/ | CC0 | 5:00 | capable |
|
||||
| `fs854438` | Immersive Ethereal Generative Ambient (Floating Harmonics) | bassimat | https://freesound.org/people/bassimat/sounds/854438/ | CC0 | 11:33 | no (richest single piece) |
|
||||
| `fma_cosmic` | Cosmic Waves | HoliznaCC0 | https://freemusicarchive.org/music/holiznacc0/space-sleep-meditation/cosmic-waves/ | CC0 | 33:04 | no (long-form melodic pads) |
|
||||
| `fma_dream` | DreamScape | HoliznaCC0 | https://freemusicarchive.org/music/holiznacc0/space-sleep-meditation/dreamscape/ | CC0 | 21:59 | no |
|
||||
| `fma_rain` | Rain / Sleep / Meditation | HoliznaCC0 | https://freemusicarchive.org/music/holiznacc0/space-sleep-meditation/rain-sleep-meditation/ | CC0 | 14:35 | no (rain + pads) |
|
||||
| `ccm_shagrugge` | How to Get Rid of a Dream (real imagination mix) | shagrugge | http://dig.ccmixter.org/files/shagrugge/12629 | CC BY 3.0 | ~ | yes-ish (binaural-filtered vocals; experimental) |
|
||||
|
||||
- The MANTICE **Sacred Drone** series is the composed extension of Round-1 #8–10 (same
|
||||
generator; the generator *does* binaural entrainment across Δ/θ/α/β, but the individual
|
||||
freesound renders don't certify the beat is on — bassimat could confirm/render on request).
|
||||
- **HoliznaCC0** (FMA, CC0) = the strongest pure-*music* options; no binaural layer.
|
||||
- FMA `files.freemusicarchive.org/...` URLs verified live (206) — safe to self-host.
|
||||
The **ccMixter** shagrugge URL is **hotlink-protected** (403) — download in a browser and self-host.
|
||||
|
||||
### Opt-in (outside the CC0/CC-BY rule — needs operator OK)
|
||||
|
||||
- **"Hang Binaural" — Mindseye** — https://freemusicarchive.org/music/MindsEye/single/hang-binaural
|
||||
— **CC BY-SA 4.0**. The single best musical+*confirmed*-binaural fit (hang-drum music +
|
||||
real binaural beats, 3:36, stereo), but ShareAlike copyleft. Add only if SA is acceptable.
|
||||
|
||||
## Rejected / could not verify
|
||||
|
||||
- **archive.org "Binaural Beats – Alpha/Beta/Theta/Delta"** & **"Isochronic Tones &
|
||||
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]]).
|
||||
@@ -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
|
||||
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,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()
|
||||
|
||||
@@ -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 0–10 "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/");
|
||||
});
|
||||
});
|
||||
@@ -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; },
|
||||
|
||||
@@ -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
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
@@ -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.
+192
-26
@@ -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;
|
||||
@@ -1369,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).
|
||||
@@ -1377,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}`;
|
||||
@@ -1393,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
|
||||
@@ -1455,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
|
||||
@@ -1486,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();
|
||||
}
|
||||
@@ -1548,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() {
|
||||
@@ -1585,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);
|
||||
}
|
||||
@@ -1608,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.)
|
||||
|
||||
@@ -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 & 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>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>
|
||||
|
||||
@@ -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,14 @@
|
||||
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>
|
||||
@@ -73,6 +80,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 +94,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,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 };
|
||||
});
|
||||
|
||||
@@ -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,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);
|
||||
});
|
||||
@@ -53,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}"
|
||||
|
||||
@@ -151,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
|
||||
|
||||
Reference in New Issue
Block a user