diff --git a/.gitignore b/.gitignore index d515d75..2060ead 100644 --- a/.gitignore +++ b/.gitignore @@ -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/ diff --git a/docs/audio-candidate-pool.md b/docs/audio-candidate-pool.md index 140c3b4..6073fa1 100644 --- a/docs/audio-candidate-pool.md +++ b/docs/audio-candidate-pool.md @@ -50,7 +50,13 @@ recordings (NASA/NOAA) keep the "machines reshaping perception" thesis literal. | πŸ– coast | `coast/waves` | Sea waves + tern calls | BigSoundBank #0267 β€” https://bigsoundbank.com/sea-waves-and-seagulls-s0267.html (`/UPLOAD/mp3/0267.mp3`) | 57s | CC0 | | πŸ•³ abyss | `abyss/whale` | NE Pacific blue-whale "AB call" | NOAA PMEL β€” https://www.pmel.noaa.gov/acoustics/multimedia/NETS_bluWhale.wav | 13s | PD (NOAA) | -## Music (the Audio-dropdown "Music" option, shipped 2026-07-01) +## Music (the Audio-dropdown "Music" option) + +> **SUPERSEDED 2026-07-02:** the single ethereal loop below was replaced by a +> **rotating crossfading playlist of mellow drumless synth** (Kevin MacLeod, CC BY) β€” +> see `docs/music-candidate-pool.md`. The ethereal-loop record is kept for history. + +### (history) ethereal loop β€” shipped 2026-07-01, then replaced **One** ethereal ambient loop, played the same at every altitude (altitude-independent β€” NOT the per-altitude idea the "cosmos/music" / "Frozen Star" rows above reserved; those diff --git a/docs/binaural-candidate-pool.md b/docs/binaural-candidate-pool.md new file mode 100644 index 0000000..6e7e1bd --- /dev/null +++ b/docs/binaural-candidate-pool.md @@ -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//_-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]]). diff --git a/docs/music-candidate-pool.md b/docs/music-candidate-pool.md new file mode 100644 index 0000000..fc42edc --- /dev/null +++ b/docs/music-candidate-pool.md @@ -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/.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. diff --git a/simulator/build_audio_media.py b/simulator/build_audio_media.py index fafc287..0d8ba75 100644 --- a/simulator/build_audio_media.py +++ b/simulator/build_audio_media.py @@ -1,42 +1,62 @@ -"""Production pass for the 5 per-altitude soundtracks + the single Music loop -(audio spec Β§5.3; Music layer per docs/audio-candidate-pool.md). The audio -analogue of build_pool_manifest.py --media: read the sourced ambience clips -(re-downloadable per docs/audio-candidate-pool.md), and make each a seamless -loudness-normalized loop. +"""Production pass for the 5 per-altitude soundtracks + the Music playlist +(audio spec Β§5.3; Music per docs/music-candidate-pool.md). The audio analogue of +build_pool_manifest.py --media: read the sourced clips (re-downloadable per the +candidate-pool docs) and make each web-ready. -Media is gitignored; this script is the reproducible record. Outputs land under -simulator/sample_media/audio/<scale>/<name>.loop.mp3, served at /media/audio/... -Run: python simulator/build_audio_media.py +- Soundtracks: seamless loudness-normalized loops -> audio/<scale>/<name>.loop.mp3. +- Music: the mellow drumless synth playlist (Kevin MacLeod, CC BY 4.0) β€” NOT looped + (the client crossfades between tracks). Each is loudness-normalized and transcoded + to a bitrate that keeps it under the ~25MB git-LFS/nginx limit (ambient tolerates + it) -> audio/music/<name>.mp3. Put the downloaded original at audio/music/<name>.src.mp3 + (URLs below) first; this transcodes it. -(White-noise is deferred β€” the live Audio control is a simple on/off soundtrack -toggle. `tools.pipeline.audio_run.generate_white_noise` remains for when it's -wanted, but no noise bed is built here.) +Media is committed via git-LFS; this script is the reproducible record. +Run: python -m simulator.build_audio_media """ from __future__ import annotations +import subprocess from pathlib import Path from tools.pipeline.audio_run import process_soundtrack +from tools.pipeline.run import resolve_ffmpeg AUDIO = Path(__file__).parent / "sample_media" / "audio" # scale -> (sourced raw file, production output file), both under audio/<scale>/. # Sources per docs/audio-candidate-pool.md (the "βœ“ KEEP" picks). -# -# The `music` entry is the Audio-dropdown "Music" layer (docs/audio-candidate-pool.md -# Β§ Music): ONE ethereal ambient loop played the same at every altitude β€” not a -# per-altitude soundtrack. It lands at audio/music/ethereal.loop.mp3, served at -# /media/audio/music/ethereal.loop.mp3 (client fallback: app.js MUSIC_FALLBACK). SOURCES: dict[str, tuple[str, str]] = { "cosmos": ("pillars.mp3", "pillars.loop.mp3"), "orbit": ("spaceamb.mp3", "spaceamb.loop.mp3"), "coast": ("waves.mp3", "waves.loop.mp3"), "reef": ("soundscape.mp3", "soundscape.loop.mp3"), "abyss": ("whale.wav", "whale.loop.mp3"), - "music": ("ethereal.mp3", "ethereal.loop.mp3"), } +# Music playlist (docs/music-candidate-pool.md Β§ Round 4): name -> (incompetech source +# URL, target bitrate). Kevin MacLeod, CC BY 4.0 β€” credited on credits.html. Drumless, +# chord-moving ambient; the operator's picks. Longer tracks get a lower bitrate to stay +# under the LFS size limit. Client playlist: app.js MUSIC_PLAYLIST. +_KM = "https://incompetech.com/music/royalty-free/mp3-royaltyfree/" +MUSIC: dict[str, tuple[str, str]] = { + "manatees": (_KM + "Music%20for%20Manatees.mp3", "96k"), + "dewdrop": (_KM + "Dewdrop%20Fantasy.mp3", "80k"), + "eastminster": (_KM + "Eastminster.mp3", "128k"), + "overheat": (_KM + "Overheat.mp3", "128k"), + "concentration": (_KM + "Concentration.mp3", "80k"), +} + + +def _transcode(src: Path, dst: Path, bitrate: str, ff: str) -> Path: + """Loudness-normalize + transcode to mp3 at `bitrate` (no looping).""" + subprocess.run( + [ff, "-y", "-hide_banner", "-loglevel", "error", "-i", str(src), + "-af", "loudnorm", "-c:a", "libmp3lame", "-b:a", bitrate, "-ar", "44100", str(dst)], + check=True, + ) + return dst + def main() -> None: for scale, (raw, out) in SOURCES.items(): @@ -47,6 +67,15 @@ def main() -> None: dst = process_soundtrack(src, AUDIO / scale / out) print(f" produced {dst.relative_to(AUDIO.parent)}") + ff = resolve_ffmpeg() + for name, (url, bitrate) in MUSIC.items(): + src = AUDIO / "music" / f"{name}.src.mp3" + if not src.exists(): + print(f" SKIP music/{name}: source missing ({src}) β€” download {url}") + continue + dst = _transcode(src, AUDIO / "music" / f"{name}.mp3", bitrate, ff) + print(f" produced {dst.relative_to(AUDIO.parent)} @ {bitrate}") + if __name__ == "__main__": main() diff --git a/simulator/e2e/tests/audio-source.spec.ts b/simulator/e2e/tests/audio-source.spec.ts index 9df942b..631f188 100644 --- a/simulator/e2e/tests/audio-source.spec.ts +++ b/simulator/e2e/tests/audio-source.spec.ts @@ -2,7 +2,7 @@ import { test, expect, Page } from "@playwright/test"; // The Audio-source feature (docs/superpowers/specs/2026-07-01-audio-music-option-design.md): // the old 0–10 "Audio" slider becomes "Volume", and a new Audio <select> chooses the -// source β€” None / Soundtrack / Music. Music is ONE ethereal loop played the same at +// source β€” None / Soundtrack / Music. Music is a rotating playlist played the same at // every altitude (no per-altitude crossfade). async function enter(page: Page) { @@ -47,27 +47,30 @@ test.describe("Audio source dropdown + Volume rename", () => { await expect(page.locator("#audio")).toBeEnabled(); }); - test("Music plays one ethereal loop that persists across an altitude change", async ({ page }) => { + const MUSIC_RE = /audio\/music\/(manatees|dewdrop|eastminster|overheat|concentration)\.mp3/; + + test("Music plays a playlist track that persists across an altitude change", async ({ page }) => { await enter(page); await selectSource(page, "music"); let urls = await audUrls(page); - expect(urls.a).toContain("music/ethereal.loop.mp3"); - // Move the dial: Music is altitude-independent, so #aud stays on the music loop - // (it does NOT switch to a per-altitude soundtrack). + expect(urls.a).toMatch(MUSIC_RE); + // Move the dial: Music is altitude-independent, so #aud stays on a music track + // (it does NOT switch to a per-altitude soundtrack). Default rotate interval is + // 5 min, so it's the SAME track here. await wheelOnStage(page, 120); await wheelOnStage(page, 120); await page.waitForTimeout(300); urls = await audUrls(page); - expect(urls.a).toContain("music/ethereal.loop.mp3"); + expect(urls.a).toMatch(MUSIC_RE); }); - test("Soundtrack loads a per-altitude ambience, not the music loop", async ({ page }) => { + test("Soundtrack loads a per-altitude ambience, not a music track", async ({ page }) => { await enter(page); await selectSource(page, "soundtrack"); await page.waitForTimeout(200); const urls = await audUrls(page); const loaded = urls.a + "|" + urls.b; expect(loaded).toContain("/audio/"); - expect(loaded).not.toContain("music/ethereal.loop.mp3"); + expect(loaded).not.toContain("/audio/music/"); }); }); diff --git a/simulator/e2e/tests/auto-advance.spec.ts b/simulator/e2e/tests/auto-advance.spec.ts new file mode 100644 index 0000000..fb5e4cf --- /dev/null +++ b/simulator/e2e/tests/auto-advance.spec.ts @@ -0,0 +1,55 @@ +import { test, expect, Page } from "@playwright/test"; + +// Auto-advance: after the dial sits idle for the interval, the altitude drifts one +// step deeper (descend), wrapping past the deepest. A default-on toggle disables it. +// The e2e injects a short interval via window.__hefAutoAdvanceMs before load. + +async function boot(page: Page, intervalMs: number) { + await page.addInitScript((ms) => { (window as any).__hefAutoAdvanceMs = ms; }, intervalMs); + await page.goto("/"); + await page.waitForFunction(() => (window as any).__hefReady === true, null, { timeout: 45_000 }); + await page.locator("#welcome-launch").click(); + await page.waitForSelector("#welcome", { state: "detached", timeout: 10_000 }); +} + +const idx = (page: Page) => page.evaluate(() => (window as any).__hefState().ringIndex as number); + +test("auto-advances one altitude (descending) after the idle interval", async ({ page }) => { + await boot(page, 1200); + const n = await page.evaluate(() => (window as any).__hefState && (document.querySelectorAll(".dial-label").length)); + const start = await idx(page); + // Wait past the interval + the morph animation for the landing. + await page.waitForFunction((s) => (window as any).__hefState().ringIndex !== s, start, { timeout: 8000 }); + const after = await idx(page); + expect(after).toBe((start + 1) % n); // descended exactly one, wraps +}); + +test("Auto-advance off keeps the altitude parked", async ({ page }) => { + // Disable BEFORE launch so the idle timer never arms (deterministic β€” no race with + // the first interval firing during boot). + await page.addInitScript(() => { (window as any).__hefAutoAdvanceMs = 800; }); + await page.goto("/"); + await page.waitForFunction(() => (window as any).__hefReady === true, null, { timeout: 45_000 }); + await page.evaluate(() => { (document.querySelector("#auto-advance") as HTMLInputElement).checked = false; }); + await page.locator("#welcome-launch").click(); + await page.waitForSelector("#welcome", { state: "detached", timeout: 10_000 }); + + const start = await idx(page); + await page.waitForTimeout(2200); // well past two 800ms intervals + expect(await idx(page)).toBe(start); // never moved +}); + +test("the idle countdown resets on interaction (no advance right after a manual turn)", async ({ page }) => { + await boot(page, 1500); + // Immediately before the first interval elapses, interact (keyboard step). That step + // itself advances by 1; the reset then means no AUTO advance fires for another + // full interval β€” so shortly after, we should still be at exactly that +1. + await page.waitForTimeout(900); + const before = await idx(page); + await page.locator("#dial").focus(); + await page.locator("#dial").press("ArrowDown"); // manual descend + reset + await page.waitForFunction((b) => (window as any).__hefState().ringIndex !== b, before, { timeout: 4000 }); + const manual = await idx(page); + await page.waitForTimeout(900); // < the 1500ms interval + expect(await idx(page)).toBe(manual); // countdown reset β†’ no extra auto step yet +}); diff --git a/simulator/e2e/tests/mood-grade.spec.ts b/simulator/e2e/tests/mood-grade.spec.ts new file mode 100644 index 0000000..1f97027 --- /dev/null +++ b/simulator/e2e/tests/mood-grade.spec.ts @@ -0,0 +1,46 @@ +import { test, expect, Page } from "@playwright/test"; + +// Bug: the Dark/Light Mood grade was dropped DURING an altitude morph β€” the morph +// showed ungraded ("normal") and the grade snapped back only on landing. Both render +// paths were at fault: the WebGL canvas set filter "none" while `busy`, and the +// fallback set #vid's filter "none" when a morph loaded. The grade must ride through. + +async function boot(page: Page) { + await page.goto("/"); + await page.waitForFunction(() => (window as any).__hefReady === true, null, { timeout: 45_000 }); + await page.locator("#welcome-launch").click(); + await page.waitForSelector("#welcome", { state: "detached", timeout: 10_000 }); + await expect(page.locator("#scale-name")).toBeVisible(); +} + +test("Dark Mood grade stays applied to the morph while scrubbing", async ({ page }) => { + await boot(page); + // Drive Mood hard dark; wait for the debounced render to build the grade. + await page.evaluate(() => { + const m = document.querySelector("#mood") as HTMLInputElement; + m.value = "-4"; + m.dispatchEvent(new Event("input", { bubbles: true })); + }); + await page.waitForTimeout(300); + + // Begin a drag β†’ a morph plays (busy = true). Sample the VISIBLE morph surface: + // the paint canvas (WebGL path) or #vid (fallback). #vid-loop is hidden mid-morph, + // so we deliberately exclude it. + const box = (await page.locator("#dial").boundingBox())!; + const cx = box.x + box.width / 2, cy = box.y + box.height / 2; + await page.mouse.move(cx, cy - 30); + await page.mouse.down(); + await page.mouse.move(cx + 18, cy - 24, { steps: 8 }); + await page.waitForFunction(() => (document.querySelector("#vid") as HTMLVideoElement).currentTime > 0, null, { timeout: 5000 }); + + const filters = await page.evaluate(() => [ + (document.querySelector("#paint") as HTMLElement).style.filter, + (document.querySelector("#vid") as HTMLElement).style.filter, + ]); + await page.mouse.up(); + + // Dark grade is brightness(<1)… β€” at least one visible surface must carry it. + // Before the fix both were "none" here. + const graded = filters.some((f) => /brightness\(/.test(f) && !/brightness\(1(\.0+)?\)/.test(f)); + expect(graded, `visible morph surface must carry the dark grade; saw ${JSON.stringify(filters)}`).toBe(true); +}); diff --git a/simulator/e2e/tests/music-playlist.spec.ts b/simulator/e2e/tests/music-playlist.spec.ts new file mode 100644 index 0000000..8dc5d95 --- /dev/null +++ b/simulator/e2e/tests/music-playlist.spec.ts @@ -0,0 +1,57 @@ +import { test, expect, Page } from "@playwright/test"; + +// Music is a rotating playlist that crossfades to a random OTHER track on a timer +// (docs/music-candidate-pool.md). The e2e injects short rotate/xfade intervals via +// window.__hefMusicRotateMs / __hefMusicXfadeMs before load. + +async function boot(page: Page, rotateMs: number, xfadeMs: number) { + await page.addInitScript(([r, x]) => { + (window as any).__hefMusicRotateMs = r; + (window as any).__hefMusicXfadeMs = x; + }, [rotateMs, xfadeMs]); + await page.goto("/"); + await page.waitForFunction(() => (window as any).__hefReady === true, null, { timeout: 45_000 }); + await page.locator("#welcome-launch").click(); + await page.waitForSelector("#welcome", { state: "detached", timeout: 10_000 }); +} + +// The track on the louder (active) music element. +async function activeTrack(page: Page): Promise<string> { + return await page.evaluate(() => { + const a = document.querySelector("#aud") as HTMLAudioElement; + const b = document.querySelector("#aud-b") as HTMLAudioElement; + const el = b.volume > a.volume ? b : a; + return el.dataset.url || ""; + }); +} + +test("Music crossfades to a DIFFERENT playlist track after the rotate interval", async ({ page }) => { + await boot(page, 1200, 300); + await page.selectOption("#audio-source", "music"); + await page.waitForTimeout(200); + const first = await activeTrack(page); + expect(first).toMatch(/audio\/music\/\w+\.mp3/); + // Wait past one rotation (1200ms) + the crossfade (300ms) + buffer. + await page.waitForFunction((f) => { + const a = document.querySelector("#aud") as HTMLAudioElement; + const b = document.querySelector("#aud-b") as HTMLAudioElement; + const el = b.volume > a.volume ? b : a; + return (el.dataset.url || "") !== f && (el.dataset.url || "").includes("/audio/music/"); + }, first, { timeout: 6000 }); + const second = await activeTrack(page); + expect(second).toMatch(/audio\/music\/\w+\.mp3/); + expect(second).not.toBe(first); // rotated to another track +}); + +test("Music keeps rotating (a second rotation lands on a track again)", async ({ page }) => { + await boot(page, 900, 250); + await page.selectOption("#audio-source", "music"); + const seen = new Set<string>(); + for (let i = 0; i < 3; i++) { + await page.waitForTimeout(1300); + seen.add(await activeTrack(page)); + } + // Over 3 rotations we should have heard more than one distinct track. + expect([...seen].every((u) => /audio\/music\/\w+\.mp3/.test(u))).toBe(true); + expect(seen.size).toBeGreaterThan(1); +}); diff --git a/simulator/e2e/tests/panel-layout.spec.ts b/simulator/e2e/tests/panel-layout.spec.ts new file mode 100644 index 0000000..8d6674a --- /dev/null +++ b/simulator/e2e/tests/panel-layout.spec.ts @@ -0,0 +1,25 @@ +import { test, expect, Page } from "@playwright/test"; + +// Guard: the "Video speed" control label must stay on ONE line in the Output panel +// (it was wrapping to two rows at the old panel width). + +async function enter(page: Page) { + await page.goto("/"); + await page.waitForFunction(() => (window as any).__hefReady === true, null, { timeout: 45_000 }); + await page.locator("#welcome-launch").click(); + await page.waitForSelector("#welcome", { state: "detached", timeout: 10_000 }); +} + +test('the "Video speed" label does not wrap to a second line', async ({ page }) => { + await enter(page); + const info = await page.evaluate(() => { + const span = document.querySelector('.panel .play-speed span[data-i18n="speed.label"]') as HTMLElement; + const cs = getComputedStyle(span); + let lh = parseFloat(cs.lineHeight); + if (Number.isNaN(lh)) lh = parseFloat(cs.fontSize) * 1.3; // "normal" + return { text: span.textContent, height: span.getBoundingClientRect().height, lineHeight: lh }; + }); + expect(info.text).toBe("Video speed"); + // A wrapped label is ~2Γ— a single line; allow slack but stay well under two lines. + expect(info.height).toBeLessThan(info.lineHeight * 1.6); +}); diff --git a/simulator/e2e/tests/url-params.spec.ts b/simulator/e2e/tests/url-params.spec.ts new file mode 100644 index 0000000..d42fd36 --- /dev/null +++ b/simulator/e2e/tests/url-params.spec.ts @@ -0,0 +1,47 @@ +import { test, expect, Page } from "@playwright/test"; + +// Shareable deep-link: ?audio=none|soundtrack|music presets the Audio source on load +// (and ?vol=0..10 the master volume), so a link opens straight into a chosen sound mode. + +async function ready(page: Page) { + await page.waitForFunction(() => (window as any).__hefReady === true, null, { timeout: 45_000 }); +} +async function launch(page: Page) { + await page.locator("#welcome-launch").click(); + await page.waitForSelector("#welcome", { state: "detached", timeout: 10_000 }); +} +const srcVal = (page: Page) => page.locator("#audio-source").inputValue(); + +test("?audio=music preselects the Music source and survives launch", async ({ page }) => { + await page.goto("/?audio=music"); + await ready(page); + expect(await srcVal(page)).toBe("music"); // set at init, before launch + await launch(page); + expect(await srcVal(page)).toBe("music"); +}); + +test("?audio=soundtrack preselects Soundtrack", async ({ page }) => { + await page.goto("/?audio=soundtrack"); + await ready(page); + expect(await srcVal(page)).toBe("soundtrack"); +}); + +test("?audio=none preselects None and disables Volume", async ({ page }) => { + await page.goto("/?audio=none"); + await ready(page); + expect(await srcVal(page)).toBe("none"); + await expect(page.locator("#audio")).toBeDisabled(); +}); + +test("an invalid ?audio value is ignored (stays default Soundtrack)", async ({ page }) => { + await page.goto("/?audio=banana"); + await ready(page); + expect(await srcVal(page)).toBe("soundtrack"); +}); + +test("?vol sets the master volume for the shared link", async ({ page }) => { + await page.goto("/?audio=music&vol=7"); + await ready(page); + await launch(page); + expect(await page.locator("#audio").inputValue()).toBe("7"); +}); diff --git a/simulator/e2e/tests/welcome-audio.spec.ts b/simulator/e2e/tests/welcome-audio.spec.ts new file mode 100644 index 0000000..4a157cf --- /dev/null +++ b/simulator/e2e/tests/welcome-audio.spec.ts @@ -0,0 +1,43 @@ +import { test, expect, Page } from "@playwright/test"; + +// The welcome (opening) screen mirrors the panel controls. It must offer the Audio +// SOURCE choice (None/Soundtrack/Music) too β€” so a visitor can pick their sound mode +// before launching β€” carried into the session on entry. + +async function ready(page: Page) { + await page.waitForFunction(() => (window as any).__hefReady === true, null, { timeout: 45_000 }); +} + +test("the welcome screen has an Audio source dropdown with 3 options", async ({ page }) => { + await page.goto("/"); + await ready(page); + await expect(page.locator("#welcome")).toBeVisible(); + const opts = page.locator("#welcome-audio-source option"); + await expect(opts).toHaveCount(3); + await expect(opts).toHaveText(["None", "Soundtrack", "Music"]); +}); + +test("choosing Music on the welcome screen carries into the session", async ({ page }) => { + await page.goto("/"); + await ready(page); + await page.selectOption("#welcome-audio-source", "music"); + await page.locator("#welcome-launch").click(); + await page.waitForSelector("#welcome", { state: "detached", timeout: 10_000 }); + expect(await page.locator("#audio-source").inputValue()).toBe("music"); + await page.waitForTimeout(300); + const url = await page.evaluate(() => (document.querySelector("#aud") as HTMLAudioElement).dataset.url || ""); + expect(url).toMatch(/audio\/music\/\w+\.mp3/); +}); + +test("welcome None greys out the welcome Volume slider", async ({ page }) => { + await page.goto("/"); + await ready(page); + await page.selectOption("#welcome-audio-source", "none"); + await expect(page.locator("#welcome-audio")).toBeDisabled(); +}); + +test("?audio=music preselects Music on the welcome screen too", async ({ page }) => { + await page.goto("/?audio=music"); + await ready(page); + expect(await page.locator("#welcome-audio-source").inputValue()).toBe("music"); +}); diff --git a/simulator/sample_media/audio/music/concentration.mp3 b/simulator/sample_media/audio/music/concentration.mp3 new file mode 100644 index 0000000..1cebc12 --- /dev/null +++ b/simulator/sample_media/audio/music/concentration.mp3 @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:8c5fb794efbf0b8e6c59c6723e1da29b61ca2b5b48c882419909797d7ddb650d +size 17929636 diff --git a/simulator/sample_media/audio/music/dewdrop.mp3 b/simulator/sample_media/audio/music/dewdrop.mp3 new file mode 100644 index 0000000..80530de --- /dev/null +++ b/simulator/sample_media/audio/music/dewdrop.mp3 @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:b96e9051603066ae8b43213a231d309b9a0234f275d4a68fb0d9a54a92d48acb +size 20979975 diff --git a/simulator/sample_media/audio/music/eastminster.mp3 b/simulator/sample_media/audio/music/eastminster.mp3 new file mode 100644 index 0000000..29d7afd --- /dev/null +++ b/simulator/sample_media/audio/music/eastminster.mp3 @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:86047b903f50275d6578b8e4051bbb75d2784cb39f3719f7e7b2b81eb58c83a2 +size 6482165 diff --git a/simulator/sample_media/audio/music/ethereal.loop.mp3 b/simulator/sample_media/audio/music/ethereal.loop.mp3 deleted file mode 100644 index 0c9fbc4..0000000 --- a/simulator/sample_media/audio/music/ethereal.loop.mp3 +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:dd4df4eac246061979d4959a95dbefa7405a57d45f6d8d1e06d1d98dad644a26 -size 3742100 diff --git a/simulator/sample_media/audio/music/ethereal.mp3 b/simulator/sample_media/audio/music/ethereal.mp3 deleted file mode 100644 index d76ac08..0000000 --- a/simulator/sample_media/audio/music/ethereal.mp3 +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:3829adf9ca64784268b21a061f0b1cf49b2b14139949f8a2ea3f33ffd04c3949 -size 4923905 diff --git a/simulator/sample_media/audio/music/manatees.mp3 b/simulator/sample_media/audio/music/manatees.mp3 new file mode 100644 index 0000000..c7b6abe --- /dev/null +++ b/simulator/sample_media/audio/music/manatees.mp3 @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:80547ef4a54a9879e1cf94f827b19da350feda1e55dd86876485dff620b5dfd1 +size 12673735 diff --git a/simulator/sample_media/audio/music/overheat.mp3 b/simulator/sample_media/audio/music/overheat.mp3 new file mode 100644 index 0000000..9115e48 --- /dev/null +++ b/simulator/sample_media/audio/music/overheat.mp3 @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:e0fa4796031f146b7f8bef55d613212c3a0f6b3de05bd48233e4229ed0a244e0 +size 7066594 diff --git a/simulator/static/app.js b/simulator/static/app.js index 18004dc..71729e2 100644 --- a/simulator/static/app.js +++ b/simulator/static/app.js @@ -451,11 +451,13 @@ function paintLoop() { if (paint.width !== w || paint.height !== h) { paint.width = w; paint.height = h; } gl.viewport(0, 0, w, h); try { gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGB, gl.RGB, gl.UNSIGNED_BYTE, src); } catch (_) {} - // No painterly restyle during a ring transition (busy) β€” show it raw. + // No painterly DREAM during a ring transition (busy) β€” show the morph crisp. + // The Dark/Light mood GRADE, however, must ride through the transition too + // (else the morph shows ungraded and the grade snaps back only on landing). gl.uniform1f(uAmt, busy ? 0 : dreamIntensity); gl.uniform2f(uTexel, 1 / w, 1 / h); gl.drawArrays(gl.TRIANGLES, 0, 3); - paint.style.filter = busy ? "none" : gradeFilter; + paint.style.filter = gradeFilter; } // Animate per-frame: re-place keyframed tracks AND re-evaluate time-windowed // labels (a label enters/leaves frame as the loop plays) against playback time. @@ -846,6 +848,7 @@ function autoScrub(targetPos, perAltMs = PER_ALTITUDE_MS) { let wheelAccum = 0, wheelTimer = null; function onWheel(e) { e.preventDefault(); + bumpAutoAdvance(); wheelAccum += e.deltaY; clearTimeout(wheelTimer); wheelTimer = setTimeout(() => { @@ -855,6 +858,27 @@ function onWheel(e) { }, 90); } +// --- Auto-advance: after the dial sits idle for AUTO_ADVANCE_MS, drift one altitude +// deeper (descend; past the deepest it wraps back to the top), a slow kiosk tour. Any +// manual interaction restarts the idle countdown; a default-on toggle disables it. +// (E2E injects a short interval via window.__hefAutoAdvanceMs before load.) +const AUTO_ADVANCE_MS = (typeof window !== "undefined" && window.__hefAutoAdvanceMs) || 60000; +let autoAdvanceTimer = null; +function autoAdvanceOn() { const el = $("auto-advance"); return el ? el.checked : true; } +function scheduleAutoAdvance() { + clearTimeout(autoAdvanceTimer); + autoAdvanceTimer = null; + if (!autoAdvanceOn() || !videoEverOn) return; // only while the experience is running + autoAdvanceTimer = setTimeout(onAutoAdvance, AUTO_ADVANCE_MS); +} +function onAutoAdvance() { + // Skip a beat (but keep the cadence) if the visitor is mid-drag or a morph is playing. + if (autoAdvanceOn() && videoEverOn && !dialDrag && !busy) autoScrub(Math.round(pos) + 1); + scheduleAutoAdvance(); +} +// Restart the idle countdown on any manual interaction (drag/wheel/key/knob). +function bumpAutoAdvance() { if (videoEverOn) scheduleAutoAdvance(); } + // --- Altitude knob: the endless rotary encoder, drawn as a turnable dial --- // cosmos (highest) sits at the top; turning CLOCKWISE descends through the scales // (cosmos β†’ orbit β†’ coast β†’ reef β†’ abyss) and past the deepest WRAPS back to the @@ -983,7 +1007,10 @@ function rebuildSegment(lo, enteredFrom) { overlay.style.opacity = "0"; affectLayer.style.opacity = "0"; tint.style.opacity = "0"; - vid.style.filter = "none"; + // WebGL path: the graded canvas sits on top, so #vid stays raw (no double-grade). + // Fallback path: #vid IS the visible surface, so it must carry the mood grade + // through the morph (setting "none" here was the "morph ignores Mood" bug). + vid.style.filter = paintOK ? "none" : gradeFilter; vid.loop = false; if (vid.dataset.morph !== file) { vid.dataset.morph = file; @@ -1059,6 +1086,7 @@ function setPos(next) { function onDialDown(e) { if (!ring || ring.scales.length < 2) return; e.preventDefault(); + bumpAutoAdvance(); dialDrag = { lastAng: dialAngle(e), accum: 0, moved: 0, target: e.target, restPos: pos }; } function onDialMove(e) { @@ -1085,6 +1113,7 @@ function onDialUp(e) { // short way to the CLOSEST one and settle there (frac 0 locks). The result is the // same as a tap/label-click: a grab-and-release always lands on a discrete altitude. autoScrub(Math.round(pos)); + bumpAutoAdvance(); } // Click a label β†’ auto-scrub the SHORTEST signed way around the ring to that scale. @@ -1115,7 +1144,7 @@ function onDialKey(e) { case "End": jumpToScale(ring.scales.length - 1); break; default: handled = false; } - if (handled) e.preventDefault(); + if (handled) { e.preventDefault(); bumpAutoAdvance(); } } // --- Dev Mode: pool picker + live analysis, all under one toggle --- @@ -1381,6 +1410,28 @@ function syncVolumeDisabled() { if (slider) slider.disabled = audioSource() === "none"; } +// Shareable deep-link: ?audio=none|soundtrack|music presets the Audio source on load +// (and ?vol=0..10 the master volume), so a link opens straight into a chosen sound mode +// β€” e.g. share …/?audio=music with someone who'd prefer music over the soundtrack. The +// source is set on the (welcome-hidden) panel dropdown; the launch gesture then applies +// it. Invalid values are ignored (the default Soundtrack stands). +function applyUrlAudioParams() { + const p = new URLSearchParams(location.search); + const src = (p.get("audio") || "").toLowerCase(); + if (src === "none" || src === "soundtrack" || src === "music") { + const el = $("audio-source"); if (el) el.value = src; + const 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). @@ -1470,15 +1521,28 @@ function scaleAudioUrl(index) { // The current resting altitude's soundtrack url (kept for the status readout). function soundtrackUrl() { return scaleAudioUrl(ringIndex); } -// The single continuous "Music" loop β€” one ethereal ambient track, played the -// same at every altitude. Fallback constant mirrors SCALE_AUDIO_FALLBACK so it -// works even against a server predating the manifest's music entry; routed through -// mediaBase() for R2 (static) / local /media (dev), like scaleAudioUrl. -const MUSIC_FALLBACK = "music/ethereal.loop.mp3"; -function musicUrl() { return mediaBase() + "audio/" + MUSIC_FALLBACK; } +// --- Music: a rotating playlist of mellow synth tracks that CROSSFADE between each +// other (not one looping track), altitude-independent β€” the same relaxing rotation at +// every altitude. Every MUSIC_ROTATE_MS it crossfades to a random OTHER track (never an +// immediate repeat), reusing the two audio elements. Intervals are injectable for tests. +const MUSIC_PLAYLIST = [ + "music/manatees.mp3", "music/dewdrop.mp3", "music/eastminster.mp3", + "music/overheat.mp3", "music/concentration.mp3", +]; +const MUSIC_ROTATE_MS = (typeof window !== "undefined" && window.__hefMusicRotateMs) || 5 * 60 * 1000; +const MUSIC_XFADE_MS = (typeof window !== "undefined" && window.__hefMusicXfadeMs) || 6000; +function musicTrackUrl(i) { + return mediaBase() + "audio/" + MUSIC_PLAYLIST[HEFScrub.wrapIndex(i, MUSIC_PLAYLIST.length)]; +} +let musicIdx = -1; // index of the track on the active music element (-1 = not started) +let musicEl = null; // the element currently carrying the active music track +let musicTimer = null; // rotation timer // The url the active source would play right now (for the diagnostic readout). -function currentAudioUrl() { return audioSource() === "music" ? musicUrl() : soundtrackUrl(); } +function currentAudioUrl() { + if (audioSource() === "music") return musicTrackUrl(musicIdx < 0 ? 0 : musicIdx); + return soundtrackUrl(); +} // Start (once) and set the volume of one element to a url. Safari needs the first // play() inside a user gesture; later programmatic plays reuse the unlocked element. @@ -1511,19 +1575,47 @@ function assignElements(urlLo, urlHi) { return { elLo: aud, elHi: audB }; } -// Both elements fade to silence (None, or Volume 0). +// Both elements fade to silence (None, or Volume 0). Also ends any music rotation. function silenceAll() { + stopMusic(); fadeVolume(aud, 0, FADE_MS, () => aud.pause()); fadeVolume(audB, 0, FADE_MS, () => audB.pause()); } -// Play the single continuous Music loop on `aud` at `vol`; fade the other out. No -// altitude crossfade β€” the same track plays the same at every altitude. -function playMusic(vol) { - ensurePlaying(aud, musicUrl(), vol); - fadeVolume(audB, 0, FADE_MS); +function scheduleMusicRotate() { + clearTimeout(musicTimer); + musicTimer = setTimeout(rotateMusic, MUSIC_ROTATE_MS); +} +// Crossfade the active music element out and a random OTHER track in on the other element. +function rotateMusic() { + if (audioSource() !== "music" || audioVol() === 0 || musicIdx < 0) { scheduleMusicRotate(); return; } + const next = HEFScrub.pickNextIndex(musicIdx, MUSIC_PLAYLIST.length, Math.random()); + const nextEl = musicEl === aud ? audB : aud; + ensurePlaying(nextEl, musicTrackUrl(next), 0); // incoming track, silent... + fadeVolume(nextEl, audioVol(), MUSIC_XFADE_MS); // ...fade it in, + fadeVolume(musicEl, 0, MUSIC_XFADE_MS, () => musicEl.pause()); // ...fade the old out. + musicEl = nextEl; musicIdx = next; + scheduleMusicRotate(); updateAudioStatus(); } +// Start the rotation (first call) or just re-apply the master volume to the active track. +// The FIRST call runs inside the audio gesture, so we prime BOTH elements here to unlock +// them on Safari (the timer-driven crossfade later plays the 2nd element outside a gesture). +function startMusic(vol) { + if (musicIdx < 0) { + musicIdx = Math.floor(Math.random() * MUSIC_PLAYLIST.length); + musicEl = aud; + ensurePlaying(aud, musicTrackUrl(musicIdx), vol); + ensurePlaying(audB, musicTrackUrl(musicIdx), 0); // prime + unlock the 2nd element (silent) + scheduleMusicRotate(); + } else { + cancelFade(musicEl); + musicEl.volume = HEFScrub.clamp01(vol); + } + updateAudioStatus(); +} +// Stop the rotation and reset (on leaving Music, or going silent). +function stopMusic() { clearTimeout(musicTimer); musicTimer = null; musicIdx = -1; musicEl = null; } // Mid-segment scrub. Soundtrack: crossfade scale `loIndex` (gain 1-frac) against // loIndex+1 (gain frac), scaled by the master volume. Music: hold the single loop. @@ -1531,7 +1623,8 @@ function playMusic(vol) { function blendAudio(loIndex, frac) { const plan = HEFScrub.audioPlan(audioSource(), audioVol(), frac); if (plan.mode === "silent") return; - if (plan.mode === "music") { playMusic(plan.vol); return; } + if (plan.mode === "music") { startMusic(plan.vol); return; } + stopMusic(); // leaving Music for the soundtrack crossfade const urlLo = scaleAudioUrl(loIndex), urlHi = scaleAudioUrl(loIndex + 1); const { elLo, elHi } = assignElements(urlLo, urlHi); ensurePlaying(elLo, urlLo, plan.lo); @@ -1545,7 +1638,8 @@ function blendAudio(loIndex, frac) { function restAudio(index) { const plan = HEFScrub.audioPlan(audioSource(), audioVol(), 0); if (plan.mode === "silent") { silenceAll(); return; } - if (plan.mode === "music") { playMusic(plan.vol); return; } + if (plan.mode === "music") { startMusic(plan.vol); return; } + stopMusic(); // soundtrack β†’ ensure the music rotation is stopped const url = scaleAudioUrl(index); const active = (audB.dataset.url === url) ? audB : aud; const idle = active === audB ? aud : audB; @@ -1577,6 +1671,7 @@ function beginExperience() { updateAudioLevelLabel(); applyAudio(); applyPlaySpeed(); + scheduleAutoAdvance(); // start the idle-drift countdown now that we're running } // Welcome screen ---------------------------------------------------------------- @@ -1587,11 +1682,18 @@ function applyWelcomeToPanel() { $("visual").checked = true; // the welcome screen assumes Video on (no toggle) const wa = $("welcome-audio"); if (wa) { $("audio").value = wa.value; updateAudioLevelLabel(); } + const ws = $("welcome-audio-source"); // carry the chosen source into the panel dropdown + if (ws) { $("audio-source").value = ws.value; syncVolumeDisabled(); } } function updateWelcomeAudioLabel() { const el = $("welcome-audio-val"); if (el) el.textContent = String(+($("welcome-audio") || {}).value || 0); } +// Volume is meaningless on None β€” grey the welcome slider too, mirroring the panel. +function syncWelcomeVolumeDisabled() { + const src = $("welcome-audio-source"), slider = $("welcome-audio"); + if (src && slider) slider.disabled = src.value === "none"; +} // Remove the welcome overlay (fade out) β€” the panel reveals via `body:has(#welcome)`. function enterSimulation() { @@ -1624,6 +1726,9 @@ function initWelcome() { updateWelcomeAudioLabel(); const wa = $("welcome-audio"); if (wa) wa.addEventListener("input", updateWelcomeAudioLabel); + const ws = $("welcome-audio-source"); + if (ws) ws.addEventListener("change", syncWelcomeVolumeDisabled); + syncWelcomeVolumeDisabled(); const launch = $("welcome-launch"); if (launch) launch.addEventListener("click", onLaunch); } @@ -1641,7 +1746,9 @@ async function main() { initWelcome(); // wire the welcome-screen controls + Launch button renderScaleReadout(); // Sliders stream on "input"; the toggles fire on "change". - for (const id of ["left", "right", "mood"]) $(id).addEventListener("input", debounced); + for (const id of ["left", "right", "mood"]) $(id).addEventListener("input", () => { bumpAutoAdvance(); debounced(); }); + // Auto-advance toggle: (re)start or cancel the idle-drift countdown on change. + $("auto-advance").addEventListener("change", scheduleAutoAdvance); // Audio level dial: set the master soundtrack gain. Reconcile SYNCHRONOUSLY in this // input gesture so the first raise from 0 unlocks + plays on Safari (which blocks // play() outside a user gesture). @@ -1650,7 +1757,10 @@ async function main() { // Audio source dropdown: reconcile SYNCHRONOUSLY in this change gesture so // switching to Soundtrack/Music unlocks + plays on Safari; None greys Volume. $("audio-source").addEventListener("change", () => { syncVolumeDisabled(); applyAudio(); debounced(); }); + applyUrlAudioParams(); // shareable ?audio=/?vol= deep-link presets syncVolumeDisabled(); + 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.) diff --git a/simulator/static/credits.html b/simulator/static/credits.html index ec973c5..aae3582 100644 --- a/simulator/static/credits.html +++ b/simulator/static/credits.html @@ -48,14 +48,18 @@ <section> <h2>Sound</h2> <p class="note"> - Soundtracks are public domain or CC0 β€” credit is courtesy, not required. + Per-altitude soundtracks are public domain or CC0 β€” credit is courtesy. The + <em>Music</em> option is Creative Commons <strong>BY</strong> β€” attribution required, given below. </p> <ul class="audio-credits"> <li><strong>NASA / Chandra X-ray Observatory (CXC/SAO)</strong> β€” cosmos sonification (public domain).</li> <li><strong>NOAA</strong> SanctSound & PMEL hydrophone recordings β€” reef/abyss biological layers (public domain).</li> <li><strong>freesound.org</strong> contributors β€” Sonicfreak (station room-tone) and DCSFX (underwater bed) (CC0).</li> <li><strong>BigSoundBank</strong> β€” sea waves & gull calls (CC0).</li> - <li><strong>deadrobotmusic</strong> (freesound.org) β€” β€œAmbient F Sharp Minor Ethereal Choir Pad” (the <em>Music</em> option) (CC0).</li> + <li><strong>Kevin MacLeod</strong> (<a href="https://incompetech.com" rel="noopener">incompetech.com</a>) β€” the + <em>Music</em> option: β€œMusic for Manatees”, β€œDewdrop Fantasy”, β€œEastminster”, β€œOverheat”, and + β€œConcentration”. Licensed under + <a href="https://creativecommons.org/licenses/by/4.0/" rel="license">Creative Commons: By Attribution 4.0</a>.</li> </ul> </section> </main> diff --git a/simulator/static/i18n.js b/simulator/static/i18n.js index b9d62ad..c7e1ab6 100644 --- a/simulator/static/i18n.js +++ b/simulator/static/i18n.js @@ -29,6 +29,7 @@ "output.audio.music": { en: "Music", es: "MΓΊsica", fr: "Musique", ja: "音ζ₯½" }, "output.volume": { en: "Volume", es: "Volumen", fr: "Volume", ja: "ιŸ³ι‡" }, "altitude.legend":{ en: "Altitude", es: "Altitud", fr: "Altitude", ja: "高度" }, + "altitude.autoadvance": { en: "Auto-advance", es: "Avance automΓ‘tico", fr: "Avance automatique", ja: "θ‡ͺ動で移動" }, "knobs.legend": { en: "Experience knobs", es: "Mandos de experiencia", fr: "Boutons d’expΓ©rience", ja: "体験぀まみ" }, "knobs.think": { en: "Think", es: "Pensar", fr: "Penser", ja: "θ€ƒγˆγ‚‹" }, "knobs.feel": { en: "Feel", es: "Sentir", fr: "Ressentir", ja: "ζ„Ÿγ˜γ‚‹" }, diff --git a/simulator/static/index.html b/simulator/static/index.html index c835324..9b7eec7 100644 --- a/simulator/static/index.html +++ b/simulator/static/index.html @@ -26,6 +26,13 @@ list="play-speed-ticks" aria-describedby="welcome-play-speed-val" /> <span id="welcome-play-speed-val">1.00Γ—</span> </label> + <label class="audio-source"><span data-i18n="output.audio">Audio</span> + <select id="welcome-audio-source" aria-label="Audio source"> + <option value="none" data-i18n="output.audio.none">None</option> + <option value="soundtrack" data-i18n="output.audio.soundtrack" selected>Soundtrack</option> + <option value="music" data-i18n="output.audio.music">Music</option> + </select> + </label> <label class="audio-level"><span data-i18n="output.volume">Volume</span> <input type="range" id="welcome-audio" min="0" max="10" value="2" step="1" /> <span id="welcome-audio-val">2</span>/10 @@ -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> diff --git a/simulator/static/scrub.js b/simulator/static/scrub.js index 7ea930d..4dc7d83 100644 --- a/simulator/static/scrub.js +++ b/simulator/static/scrub.js @@ -44,6 +44,16 @@ return { mode: "blend", lo: g.from * level, hi: g.to * level }; } + // Pick the next track in a rotating playlist: a random index in [0,count) that is + // NEVER `current` (so a rotation always changes track), given r in [0,1). Maps r + // onto the (count-1) other slots. count<=1 stays on 0. Pure so the shuffle is + // node-testable; the caller passes Math.random(). + function pickNextIndex(current, count, r) { + if (count <= 1) return 0; + const k = Math.min(count - 2, Math.floor(clamp01(r) * (count - 1))); + return k >= current ? k + 1 : k; + } + // The base-loop frame a landing must continue from so the steady loop picks up // the EXACT frame the morph last showed β€” no jump. A morph spans src@0 (frac 0) // -> dst@loopTailS (frac 1), scrubbed bidirectionally. Descending (dir>0) lands @@ -68,5 +78,5 @@ return out; } - return { clamp01, wrapIndex, segmentOf, accumToPos, fracToTime, crossfadeGains, audioPlan, loopLandFrame, integerCrossings }; + return { clamp01, wrapIndex, segmentOf, accumToPos, fracToTime, crossfadeGains, audioPlan, pickNextIndex, loopLandFrame, integerCrossings }; }); diff --git a/simulator/static/style.css b/simulator/static/style.css index 2bba2f2..7bc2081 100644 --- a/simulator/static/style.css +++ b/simulator/static/style.css @@ -97,7 +97,7 @@ main { display: flex; gap: 1rem; padding: 1rem; flex-wrap: wrap; align-items: fl .audio-status { margin-top: 0.5rem; font: 11px/1.4 monospace; color: #9ab; } .audio-status.playing { color: #4e9; } .audio-status.blocked { color: #f97; } -.panel { flex: 0 0 280px; display: flex; flex-direction: column; gap: 0.8rem; +.panel { flex: 0 0 300px; display: flex; flex-direction: column; gap: 0.8rem; max-height: calc(100vh - 2rem); overflow-y: auto; position: sticky; top: 1rem; } /* The control panel stays hidden behind the welcome screen and appears only once @@ -151,7 +151,10 @@ input[type=range], select { width: 100%; } /* Audio level dial (0–10): label Β· slider Β· value on one row, matching the switch look. */ .audio-level { display: flex; align-items: center; gap: 0.5rem; margin-top: 0.45rem; color: #9af; font-weight: 600; letter-spacing: 0.3px; } -.audio-level input[type=range] { flex: 1; width: auto; } +/* Keep the control label on one line ("Video speed" was wrapping); let the range + shrink (min-width:0) instead of pushing the label to a second row. */ +.audio-level > span:first-child { white-space: nowrap; } +.audio-level input[type=range] { flex: 1; width: auto; min-width: 0; } #audio-level-val { font-variant-numeric: tabular-nums; min-width: 1.2em; text-align: right; } /* Playback speed (βˆ’2×…2Γ—) reuses the audio-level row look; wider value field for the sign + decimals. */ #play-speed-val, #welcome-play-speed-val { font-variant-numeric: tabular-nums; min-width: 3.4em; text-align: right; } diff --git a/simulator/unit/audio.test.js b/simulator/unit/audio.test.js index 10fa606..451c3bc 100644 --- a/simulator/unit/audio.test.js +++ b/simulator/unit/audio.test.js @@ -26,6 +26,26 @@ test("audioPlan: Music ignores altitude fraction (constant across the dial)", () assert.deepEqual(a, b); }); +test("pickNextIndex: never returns the current track; covers the rest", () => { + // r in [0,1) maps onto the (count-1) other indices, skipping `current`. + assert.equal(S.pickNextIndex(0, 5, 0), 1); + assert.equal(S.pickNextIndex(0, 5, 0.999), 4); + assert.equal(S.pickNextIndex(2, 5, 0), 0); + assert.equal(S.pickNextIndex(2, 5, 0.5), 3); + assert.equal(S.pickNextIndex(4, 5, 0.999), 3); + // sweep: for each current, every r lands somewhere valid and never on current + for (let cur = 0; cur < 5; cur++) { + for (let i = 0; i < 20; i++) { + const n = S.pickNextIndex(cur, 5, i / 20); + assert.ok(n >= 0 && n < 5 && n !== cur, `cur=${cur} r=${i / 20} -> ${n}`); + } + } +}); + +test("pickNextIndex: single-track playlist stays on 0", () => { + assert.equal(S.pickNextIndex(0, 1, 0.7), 0); +}); + test("audioPlan: Soundtrack blends lo/hi by fraction, scaled by level", () => { assert.deepEqual(S.audioPlan("soundtrack", 1, 0), { mode: "blend", lo: 1, hi: 0 }); assert.deepEqual(S.audioPlan("soundtrack", 1, 1), { mode: "blend", lo: 0, hi: 1 });