Compare commits

..

10 Commits

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

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

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 18:02:22 -07:00
benstull 04c8855ddc Merge pull request 'Experience tuning: mood-through-morphs fix, auto-advance, label wrap, binaural candidates' (#33) from feat/experience-tuning into main 2026-07-03 01:00:21 +00:00
BenStullsBets da3581afea feat(audio): add the Audio source dropdown to the welcome screen
The opening screen only had Volume + Video speed — no way to pick the sound mode
before launching. Add a welcome Audio dropdown (None/Soundtrack/Music) mirroring
the panel; applyWelcomeToPanel carries the choice into the session on launch, None
greys the welcome Volume, and ?audio= presets it on the welcome screen too. +4 e2e
(welcome-audio.spec.ts).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 16:56:17 -07:00
BenStullsBets 4f4f21a767 feat(audio): Music becomes a crossfading playlist of 5 mellow synth tracks
Replace the single ethereal loop with the operator's chosen set — Music for
Manatees, Dewdrop Fantasy, Eastminster, Overheat, Concentration (Kevin MacLeod,
CC BY 4.0, drumless). The Music source now plays one track and every 5 min
crossfades to a random OTHER track (pure HEFScrub.pickNextIndex, never an
immediate repeat), altitude-independent, reusing the two audio elements; both
primed in-gesture for Safari. Intervals injectable via window.__hefMusicRotateMs
/ __hefMusicXfadeMs.

Media loudness-normalized + transcoded under the 25MB LFS limit (build_audio_media
MUSIC), committed via LFS; ethereal loop retired. CC-BY attribution added to
credits.html. Docs updated (music-candidate-pool = final set; audio-candidate-pool
ethereal section superseded).

+2 node unit (pickNextIndex), +2 e2e (music-playlist), audio-source spec updated.
40 unit + 49 e2e green; live probe confirms playback + rotation.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 16:22:28 -07:00
BenStullsBets 969236b3f7 feat(audio): shareable ?audio= deep-link presets the sound mode
?audio=none|soundtrack|music (and ?vol=0..10) presets the Audio source/volume on
load, so a link opens straight into a chosen sound mode — e.g. share …/?audio=music
with someone who'd prefer music, …/?audio=soundtrack for the ambience. Invalid
values ignored (default Soundtrack stands). +5 e2e (url-params.spec.ts).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 16:00:59 -07:00
23 changed files with 480 additions and 120 deletions
+7 -1
View File
@@ -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
+15 -6
View File
@@ -12,13 +12,22 @@ URL). Local review gallery (gitignored, like the other review pages):
**Honesty flag:** licenses + URLs are verified; the "harmony" notes are inferred from
genre/reputation, not a by-ear check — audition before final selection.
## Operator picks & refined brief (2026-07-02)
## FINAL selection — WIRED 2026-07-02
Operator auditioned and **kept `dreamcult` (Dream Culture) + `silverblue` (Silver Blue Light)**
— both Kevin MacLeod, CC BY, drumless cinematic-calm synth. **Rejected everything else** as
"boring OR too much snare drum." So the refined target is **drumless, evolving-chord ambient
synth** in that exact lane — the HoliznaCC0 lo-fi (beat-driven) is OUT. A Round-4 search is
sourcing ~812 more like the two keepers (Kevin MacLeod's Calming/Ambient catalog first).
After a Round-4 drumless pass, the operator's **final Music set = 5 Kevin MacLeod tracks**
(CC BY 4.0), now **shipped as a rotating crossfading playlist**:
**Music for Manatees · Dewdrop Fantasy · Eastminster · Overheat · Concentration**
Wiring: `app.js` `MUSIC_PLAYLIST` — the Music source plays one track and every
`MUSIC_ROTATE_MS` (5 min) crossfades to a random OTHER track (`pickNextIndex`, never an
immediate repeat), altitude-independent, on the two `#aud`/`#aud-b` elements. Files
loudness-normalized + transcoded under the 25MB LFS limit by `build_audio_media.py`
(`MUSIC`), committed via LFS; the single ethereal loop was **retired**. Credited on
`credits.html` (CC BY attribution). Earlier candidates below kept for record.
*(History: the operator first kept Dream Culture + Silver Blue Light, then — after the
drumless Round-4 pass — chose the 5 long-form/floaty tracks above instead.)*
## Two license families
+46 -17
View File
@@ -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()
+11 -8
View File
@@ -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 010 "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/");
});
});
+38 -37
View File
@@ -1,55 +1,56 @@
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.
// 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, intervalMs: number) {
await page.addInitScript((ms) => { (window as any).__hefAutoAdvanceMs = ms; }, intervalMs);
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"));
});
}
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));
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);
// Wait past the interval + the morph animation for the landing.
await completeLoopVideo(page);
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
expect(await idx(page)).toBe((start + 1) % n);
});
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 });
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; });
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
await completeLoopVideo(page);
await page.waitForTimeout(1500);
expect(await idx(page)).toBe(start);
});
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
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,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
});
+47
View File
@@ -0,0 +1,47 @@
import { test, expect, Page } from "@playwright/test";
// Shareable deep-link: ?audio=none|soundtrack|music presets the Audio source on load
// (and ?vol=0..10 the master volume), so a link opens straight into a chosen sound mode.
async function ready(page: Page) {
await page.waitForFunction(() => (window as any).__hefReady === true, null, { timeout: 45_000 });
}
async function launch(page: Page) {
await page.locator("#welcome-launch").click();
await page.waitForSelector("#welcome", { state: "detached", timeout: 10_000 });
}
const srcVal = (page: Page) => page.locator("#audio-source").inputValue();
test("?audio=music preselects the Music source and survives launch", async ({ page }) => {
await page.goto("/?audio=music");
await ready(page);
expect(await srcVal(page)).toBe("music"); // set at init, before launch
await launch(page);
expect(await srcVal(page)).toBe("music");
});
test("?audio=soundtrack preselects Soundtrack", async ({ page }) => {
await page.goto("/?audio=soundtrack");
await ready(page);
expect(await srcVal(page)).toBe("soundtrack");
});
test("?audio=none preselects None and disables Volume", async ({ page }) => {
await page.goto("/?audio=none");
await ready(page);
expect(await srcVal(page)).toBe("none");
await expect(page.locator("#audio")).toBeDisabled();
});
test("an invalid ?audio value is ignored (stays default Soundtrack)", async ({ page }) => {
await page.goto("/?audio=banana");
await ready(page);
expect(await srcVal(page)).toBe("soundtrack");
});
test("?vol sets the master volume for the shared link", async ({ page }) => {
await page.goto("/?audio=music&vol=7");
await ready(page);
await launch(page);
expect(await page.locator("#audio").inputValue()).toBe("7");
});
+43
View File
@@ -0,0 +1,43 @@
import { test, expect, Page } from "@playwright/test";
// The welcome (opening) screen mirrors the panel controls. It must offer the Audio
// SOURCE choice (None/Soundtrack/Music) too — so a visitor can pick their sound mode
// before launching — carried into the session on entry.
async function ready(page: Page) {
await page.waitForFunction(() => (window as any).__hefReady === true, null, { timeout: 45_000 });
}
test("the welcome screen has an Audio source dropdown with 3 options", async ({ page }) => {
await page.goto("/");
await ready(page);
await expect(page.locator("#welcome")).toBeVisible();
const opts = page.locator("#welcome-audio-source option");
await expect(opts).toHaveCount(3);
await expect(opts).toHaveText(["None", "Soundtrack", "Music"]);
});
test("choosing Music on the welcome screen carries into the session", async ({ page }) => {
await page.goto("/");
await ready(page);
await page.selectOption("#welcome-audio-source", "music");
await page.locator("#welcome-launch").click();
await page.waitForSelector("#welcome", { state: "detached", timeout: 10_000 });
expect(await page.locator("#audio-source").inputValue()).toBe("music");
await page.waitForTimeout(300);
const url = await page.evaluate(() => (document.querySelector("#aud") as HTMLAudioElement).dataset.url || "");
expect(url).toMatch(/audio\/music\/\w+\.mp3/);
});
test("welcome None greys out the welcome Volume slider", async ({ page }) => {
await page.goto("/");
await ready(page);
await page.selectOption("#welcome-audio-source", "none");
await expect(page.locator("#welcome-audio")).toBeDisabled();
});
test("?audio=music preselects Music on the welcome screen too", async ({ page }) => {
await page.goto("/?audio=music");
await ready(page);
expect(await page.locator("#welcome-audio-source").inputValue()).toBe("music");
});
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+133 -42
View File
@@ -328,6 +328,7 @@ function ensureClipMedia() {
// tail. The morph element (#vid) is driven directly by the scrub.
loopVid.addEventListener("timeupdate", () => {
if (loopVid.dataset.loopTail === "1" && loopVid.duration && loopVid.currentTime >= loopVid.duration - 0.08) {
if (autoAdvanceOnLoopComplete()) return; // completed a playthrough → next altitude
try { loopVid.currentTime = loopStartFrame(); } catch (e) { /* not seekable */ }
}
});
@@ -339,6 +340,7 @@ loopVid.addEventListener("timeupdate", () => {
// rarely hits this; loaded real machines do.
loopVid.addEventListener("ended", () => {
if (loopVid.dataset.loopTail !== "1") return;
if (autoAdvanceOnLoopComplete()) return; // completed a playthrough → next altitude
try { loopVid.currentTime = loopStartFrame(); } catch (e) { /* not seekable */ }
loopVid.play().catch(() => {});
});
@@ -848,7 +850,6 @@ 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(() => {
@@ -858,26 +859,19 @@ 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;
// --- 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 scheduleAutoAdvance() {
clearTimeout(autoAdvanceTimer);
autoAdvanceTimer = null;
if (!autoAdvanceOn() || !videoEverOn) return; // only while the experience is running
autoAdvanceTimer = setTimeout(onAutoAdvance, AUTO_ADVANCE_MS);
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;
}
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
@@ -1086,7 +1080,6 @@ 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) {
@@ -1113,7 +1106,6 @@ 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.
@@ -1144,7 +1136,7 @@ function onDialKey(e) {
case "End": jumpToScale(ring.scales.length - 1); break;
default: handled = false;
}
if (handled) { e.preventDefault(); bumpAutoAdvance(); }
if (handled) e.preventDefault();
}
// --- Dev Mode: pool picker + live analysis, all under one toggle ---
@@ -1410,6 +1402,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).
@@ -1444,7 +1458,7 @@ function updateAudioStatus() {
}
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
@@ -1499,15 +1513,30 @@ 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. 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() { 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.
@@ -1540,19 +1569,69 @@ 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);
// 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.
@@ -1560,7 +1639,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);
@@ -1574,7 +1654,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;
@@ -1606,7 +1687,6 @@ function beginExperience() {
updateAudioLevelLabel();
applyAudio();
applyPlaySpeed();
scheduleAutoAdvance(); // start the idle-drift countdown now that we're running
}
// Welcome screen ----------------------------------------------------------------
@@ -1617,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() {
@@ -1654,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);
}
@@ -1671,9 +1761,7 @@ 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", () => { bumpAutoAdvance(); debounced(); });
// Auto-advance toggle: (re)start or cancel the idle-drift countdown on change.
$("auto-advance").addEventListener("change", scheduleAutoAdvance);
for (const id of ["left", "right", "mood"]) $(id).addEventListener("input", debounced);
// 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).
@@ -1682,7 +1770,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.)
+6 -2
View File
@@ -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 &amp; PMEL hydrophone recordings — reef/abyss biological layers (public domain).</li>
<li><strong>freesound.org</strong> contributors — Sonicfreak (station room-tone) and DCSFX (underwater bed) (CC0).</li>
<li><strong>BigSoundBank</strong> — sea waves &amp; gull calls (CC0).</li>
<li><strong>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>
+7
View File
@@ -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
+11 -1
View File
@@ -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 };
});
+20
View File
@@ -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 });
+10
View File
@@ -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}"
+4
View File
@@ -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