SFX
Soundscape
Isolate SFX
The Score
Select Genre
`;
row.addEventListener("click", e => {
e.stopPropagation();
if (genreDd.classList.contains("sai-sah-open")) {
setGenre(g.id);
genreDd.classList.remove("sai-sah-open");
} else {
genreDd.classList.add("sai-sah-open");
}
});
genreOpts.appendChild(row);
});
genreDd.addEventListener("click", () => genreDd.classList.toggle("sai-sah-open"));
genreDd.addEventListener("mouseenter", () => genreDd.classList.add("sai-sah-open"));
genreDd.addEventListener("mouseleave", () => genreDd.classList.remove("sai-sah-open"));
/* ════════════════════════════════════════════════════════════
LENS CURSOR + CALLOUT — visible only while exploring
════════════════════════════════════════════════════════════ */
const scene = document.getElementById("saiScene");
const lens = document.getElementById("saiLens");
const sfxTag = document.getElementById("saiSfxTag");
const sfxTagText = document.getElementById("saiSfxTagText");
const calloutClip = document.getElementById("saiCalloutClip");
const calloutLine = document.getElementById("saiCalloutLine");
const calloutSeg = document.getElementById("saiCalloutSeg");
const hiddenClip = () => tagSide === "left"
? "inset(-2px 0px -2px 100%)"
: "inset(-2px 100% -2px 0px)";
const closeup = document.getElementById("saiCloseup");
const LENS_W = 117.703, LENS_H = 115.892;
const TAG_GAP = 72.881; /* connector length, from the frame */
const TAG_H = 50.217;
let lensPos = { x: 0, y: 0 };
let lensTarget = { x: 0, y: 0 };
let inScene = false;
let hoverRegion = null;
let tagSide = "left";
function stageXY(e) {
const r = stage.getBoundingClientRect();
return { x: (e.clientX - r.left) / stageScale, y: (e.clientY - r.top) / stageScale };
}
/* the lens zone is the whole right side of the hero, overlay UI included */
const LENS_ZONE = { x1: 460, y1: 75, x2: 1441, y2: 800 };
function enterZone(p) {
inScene = true;
lensPos = { ...p }; /* snap so the lens doesn't fly in */
lensTarget = { ...p };
lens.classList.add("sai-sah-on");
stage.classList.add("sai-sah-lenszone");
}
function leaveZone() {
inScene = false;
hoverRegion = null;
lens.classList.remove("sai-sah-on");
stage.classList.remove("sai-sah-lenszone");
setSfx(null);
closeup.classList.remove("sai-sah-live");
updateCallout();
}
window.addEventListener("pointermove", e => {
/* the Score picker, mute pill + play ring are UI islands: real cursor */
if (e.target && e.target.closest && e.target.closest(".sai-sah-genre-float, .sai-sah-mute-btn, .sai-sah-play-ring")) {
if (inScene) leaveZone();
return;
}
const p = stageXY(e);
const inZone = p.x >= LENS_ZONE.x1 && p.x <= LENS_ZONE.x2 &&
p.y >= LENS_ZONE.y1 && p.y <= LENS_ZONE.y2;
if (inZone && !inScene) enterZone(p);
else if (!inZone && inScene) { leaveZone(); return; }
if (!inZone) return;
lensTarget = p;
const hit = REGIONS.find(r => p.x >= r.x && p.x <= r.x + r.w && p.y >= r.y && p.y <= r.y + r.h) || null;
if ((hit && hit.id) !== (hoverRegion && hoverRegion.id)) setSfxForRegion(hit);
});
document.documentElement.addEventListener("mouseleave", leaveZone);
function setSfxForRegion(hit) {
hoverRegion = hit;
setSfx(hit ? hit.id : null);
closeup.classList.toggle("sai-sah-live", !!hit);
updateCallout();
}
const sfxWave = document.getElementById("saiSfxWave");
const sfxWaveCtx = sfxWave.getContext("2d");
let lineShown = false;
const SHOWN_CLIP = "inset(-2px 0px -2px 0px)";
function lineIsHidden() {
const cp = getComputedStyle(calloutClip).clipPath;
return !cp || cp === "none" || cp.includes("100%");
}
/* collapse back into the lens from wherever the draw-out got to —
the single hide path, used by region-exit, island-crossing, zone-exit */
function retractLine() {
const from = getComputedStyle(calloutClip).clipPath;
calloutClip.getAnimations().forEach(a => a.cancel());
calloutClip.animate(
[{ clipPath: from && from !== "none" && !from.includes("100%") ? from : SHOWN_CLIP },
{ clipPath: hiddenClip() }],
{ duration: 300, easing: "ease-in", fill: "both" }
);
}
function updateCallout() {
if (!hoverRegion || !inScene) {
sfxTag.classList.remove("sai-sah-show");
sfxWave.classList.remove("sai-sah-show");
if (lineShown || !lineIsHidden()) {
lineShown = false;
retractLine();
}
return;
}
sfxTagText.textContent = hoverRegion.label;
layoutCallout(); /* fix side + geometry before animating */
calloutClip.getAnimations().forEach(a => a.cancel());
calloutClip.animate(
[{ clipPath: hiddenClip() }, { clipPath: SHOWN_CLIP }],
{ duration: 480, easing: "ease-out", fill: "both" }
);
lineShown = true;
sfxTag.classList.add("sai-sah-show"); /* CSS transition delays do the sequencing */
sfxWave.classList.add("sai-sah-show");
}
function layoutCallout() {
const cx = lensPos.x, cy = lensPos.y;
lens.style.left = (cx - LENS_W / 2) + "px";
lens.style.top = (cy - LENS_H / 2) + "px";
/* keep the line pinned to the lens while it retracts, too */
const retracting = calloutClip.getAnimations().some(a => a.playState === "running");
if (!hoverRegion && !retracting) return;
const tagW = sfxTag.offsetWidth;
/* tag ALWAYS sits left of the lens, every region — no flipping */
tagSide = "left";
let lineX1, tagX;
if (tagSide === "left") {
const lineX2 = cx - LENS_W / 2;
lineX1 = lineX2 - TAG_GAP;
tagX = lineX1 - tagW;
} else {
lineX1 = cx + LENS_W / 2;
tagX = lineX1 + TAG_GAP;
}
calloutClip.style.left = lineX1 + "px";
calloutClip.style.top = (cy - 0.5) + "px";
calloutLine.setAttribute("width", TAG_GAP);
calloutLine.setAttribute("height", 2);
calloutSeg.setAttribute("x1", 0);
calloutSeg.setAttribute("y1", 0.5);
calloutSeg.setAttribute("x2", TAG_GAP);
calloutSeg.setAttribute("y2", 0.5);
sfxTag.style.left = tagX + "px";
sfxTag.style.top = (cy - TAG_H / 2) + "px";
/* mini SFX visualizer rides just under the tag */
sfxWave.style.left = tagX + "px";
sfxWave.style.width = tagW + "px";
sfxWave.style.top = (cy + TAG_H / 2 + 7) + "px";
}
/* ════════════════════════════════════════════════════════════
RENDER LOOPS
════════════════════════════════════════════════════════════ */
const still = document.getElementById("saiSceneStill");
let video = null;//document.getElementById("saiSceneVideo");
let sceneSource = still;
// if (SCENE_VIDEO) {
// video.muted = true;
// video.src = SCENE_VIDEO;
// video.style.display = "";
// /* composite mode: the still stays visible as the full-bleed base layer */
// sceneSource = video;
// const tryPlayVideo = () => { if (video.paused) video.play().catch(() => {}); };
// video.addEventListener("canplay", tryPlayVideo);
// window.addEventListener("pointerdown", tryPlayVideo);
// window.addEventListener("pointermove", tryPlayVideo, { once: true });
// tryPlayVideo();
// }
const magCanvas = document.getElementById("saiMagCanvas");
const magCtx = magCanvas.getContext("2d");
const waveCanvas = document.getElementById("saiWaveCanvas");
const waveCtx = waveCanvas.getContext("2d");
const freq = new Uint8Array(analyser.frequencyBinCount);
/* the film's effective display rect on stage: full height at scale 0.75,
anchored so her face lands at the Figma position */
const SCENE_RECT = { x: 147.25, y: 0, w: 1440, h: 810 };
const ZOOM = 2.6;
function srcSize() {
if (sceneSource === video) return { w: video.videoWidth || 3840, h: video.videoHeight || 2160 };
return { w: still.naturalWidth || 834, h: still.naturalHeight || 480 };
}
function drawMag() {
if (!closeup.classList.contains("sai-sah-live")) return;
const { w: sw, h: sh } = srcSize();
const scale = Math.max(SCENE_RECT.w / sw, SCENE_RECT.h / sh);
const visW = SCENE_RECT.w / scale, visH = SCENE_RECT.h / scale;
const offX = (sw - visW) / 2, offY = (sh - visH) / 2;
const u = offX + ((lensPos.x - SCENE_RECT.x) / SCENE_RECT.w) * visW;
const v = offY + ((lensPos.y - SCENE_RECT.y) / SCENE_RECT.h) * visH;
const cw = (245.816 / ZOOM) / scale, ch = (147.49 / ZOOM) / scale;
const sx = Math.min(Math.max(u - cw / 2, 0), sw - cw);
const sy = Math.min(Math.max(v - ch / 2, 0), sh - ch);
try {
magCtx.drawImage(sceneSource, sx, sy, cw, ch, 0, 0, magCanvas.width, magCanvas.height);
} catch (e) {}
}
/* visualizer: nested oscilloscope lines through a solid centerline,
colored with the Studio Dusk gradient
(Black Obsidian → Builder Purple → Warm Ivory) */
const RAMP = (() => {
const c = document.createElement("canvas");
c.width = 256; c.height = 1;
const g = c.getContext("2d");
const lg = g.createLinearGradient(0, 0, 256, 0);
/* Plum Ivory: Maker Plum → Craft Purple → Warm Ivory */
lg.addColorStop(0, "#725D91");
lg.addColorStop(0.42, "#A188D4");
lg.addColorStop(1, "#EDE5E1");
g.fillStyle = lg; g.fillRect(0, 0, 256, 1);
return g.getImageData(0, 0, 256, 1).data;
})();
function rampColor(t, a = 1) {
const i = Math.max(0, Math.min(255, Math.round(t * 255))) * 4;
return `rgba(${RAMP[i]},${RAMP[i + 1]},${RAMP[i + 2]},${a})`;
}
const P = 28; /* control points across the card */
const env = new Float32Array(P); /* envelope A, eased per frame */
const envB = new Float32Array(P); /* envelope B: second shape the ribbon sweeps to */
/* ribbon visualizer: hairline strands interpolate between two audio-driven
wave shapes, pinching where they cross; additive blend + the full Plum
Ivory ramp across the ribbon's thickness */
function drawWave() {
const W = waveCanvas.width, H = waveCanvas.height, mid = H / 2;
waveCtx.clearRect(0, 0, W, H);
const now = performance.now();
analyser.getByteFrequencyData(freq);
for (let i = 0; i < P; i++) {
let ampA, ampB;
if (started) {
const binA = Math.floor(Math.pow(i / (P - 1), 1.6) * freq.length * 0.72);
const binB = Math.floor(Math.pow(1 - i / (P - 1), 1.6) * freq.length * 0.72);
ampA = 0.18 + Math.min(1, (freq[binA] / 255) * 2.1) * 0.82;
ampB = 0.18 + Math.min(1, (freq[binB] / 255) * 2.1) * 0.82;
} else {
ampA = 0.45 + 0.2 * Math.sin(i * 0.5 + now / 900);
ampB = 0.45 + 0.2 * Math.sin(i * 0.42 - now / 1100);
}
const vA = ampA * Math.sin(i * 0.85 - now / 480);
const vB = ampB * Math.sin(i * 0.68 + now / 620 + 1.9);
env[i] += (vA - env[i]) * 0.3;
envB[i] += (vB - envB[i]) * 0.3;
}
for (let i = 1; i < P - 1; i++) {
env[i] = (env[i - 1] + env[i] * 2 + env[i + 1]) / 4;
envB[i] = (envB[i - 1] + envB[i] * 2 + envB[i + 1]) / 4;
}
waveCtx.globalCompositeOperation = "lighter"; /* crossings glow */
const STRANDS = 18;
for (let j = 0; j < STRANDS; j++) {
const f = j / (STRANDS - 1);
waveCtx.beginPath();
for (let i = 0; i < P; i++) {
const x = (i / (P - 1)) * W;
const y = mid + (env[i] * (1 - f) + envB[i] * f) * H * 0.45;
if (i === 0) waveCtx.moveTo(x, y);
else {
const px = ((i - 1) / (P - 1)) * W;
const py = mid + (env[i - 1] * (1 - f) + envB[i - 1] * f) * H * 0.45;
waveCtx.quadraticCurveTo(px, py, (px + x) / 2, (py + y) / 2);
}
}
waveCtx.strokeStyle = rampColor(f, 0.3); /* full ramp across the ribbon */
waveCtx.lineWidth = 1.1;
waveCtx.lineCap = "round"; waveCtx.lineJoin = "round";
waveCtx.stroke();
}
waveCtx.globalCompositeOperation = "source-over";
}
/* mini SFX visualizer: same ribbon technique, smaller strand count */
const sfxFreq = new Uint8Array(sfxAnalyser.frequencyBinCount);
const P2 = 20;
const sfxEnv = new Float32Array(P2);
const sfxEnvB = new Float32Array(P2);
function drawSfxWave() {
if (!sfxWave.classList.contains("sai-sah-show") && !sfxEnv.some(v => Math.abs(v) > 0.01)) return;
const W = sfxWave.width, H = sfxWave.height, mid = H / 2;
sfxWaveCtx.clearRect(0, 0, W, H);
const now = performance.now();
sfxAnalyser.getByteFrequencyData(sfxFreq);
for (let i = 0; i < P2; i++) {
const binA = Math.floor(Math.pow(i / (P2 - 1), 1.5) * sfxFreq.length * 0.7);
const binB = Math.floor(Math.pow(1 - i / (P2 - 1), 1.5) * sfxFreq.length * 0.7);
const ampA = Math.min(1, (sfxFreq[binA] / 255) * 2.6);
const ampB = Math.min(1, (sfxFreq[binB] / 255) * 2.6);
const vA = ampA * Math.sin(i * 0.95 - now / 420);
const vB = ampB * Math.sin(i * 0.74 + now / 540 + 1.7);
sfxEnv[i] += (vA - sfxEnv[i]) * 0.4;
sfxEnvB[i] += (vB - sfxEnvB[i]) * 0.4;
}
for (let i = 1; i < P2 - 1; i++) {
sfxEnv[i] = (sfxEnv[i - 1] + sfxEnv[i] * 2 + sfxEnv[i + 1]) / 4;
sfxEnvB[i] = (sfxEnvB[i - 1] + sfxEnvB[i] * 2 + sfxEnvB[i + 1]) / 4;
}
sfxWaveCtx.globalCompositeOperation = "lighter";
const STRANDS = 10;
for (let j = 0; j < STRANDS; j++) {
const f = j / (STRANDS - 1);
sfxWaveCtx.beginPath();
for (let i = 0; i < P2; i++) {
const x = (i / (P2 - 1)) * W;
const y = mid + (sfxEnv[i] * (1 - f) + sfxEnvB[i] * f) * H * 0.42;
if (i === 0) sfxWaveCtx.moveTo(x, y);
else {
const px = ((i - 1) / (P2 - 1)) * W;
const py = mid + (sfxEnv[i - 1] * (1 - f) + sfxEnvB[i - 1] * f) * H * 0.42;
sfxWaveCtx.quadraticCurveTo(px, py, (px + x) / 2, (py + y) / 2);
}
}
sfxWaveCtx.strokeStyle = rampColor(f, 0.38);
sfxWaveCtx.lineWidth = 1.1;
sfxWaveCtx.lineCap = "round"; sfxWaveCtx.lineJoin = "round";
sfxWaveCtx.stroke();
}
sfxWaveCtx.globalCompositeOperation = "source-over";
}
/* true magnification inside the lens (blended over the glass refraction) */
const lensMag = document.getElementById("saiLensMag");
const lensMagCtx = lensMag.getContext("2d");
const LENS_ZOOM = 1.4;
function drawLens() {
if (!lens.classList.contains("sai-sah-on")) return;
const { w: sw, h: sh } = srcSize();
const scale = Math.max(SCENE_RECT.w / sw, SCENE_RECT.h / sh);
const visW = SCENE_RECT.w / scale, visH = SCENE_RECT.h / scale;
const offX = (sw - visW) / 2, offY = (sh - visH) / 2;
const u = offX + ((lensPos.x - SCENE_RECT.x) / SCENE_RECT.w) * visW;
const v = offY + ((lensPos.y - SCENE_RECT.y) / SCENE_RECT.h) * visH;
const cw = (LENS_W / LENS_ZOOM) / scale, ch = (LENS_H / LENS_ZOOM) / scale;
const sx = Math.min(Math.max(u - cw / 2, 0), sw - cw);
const sy = Math.min(Math.max(v - ch / 2, 0), sh - ch);
try {
lensMagCtx.drawImage(sceneSource, sx, sy, cw, ch, 0, 0, lensMag.width, lensMag.height);
} catch (e) {}
}
function tick() {
requestAnimationFrame(tick);
lensPos.x += (lensTarget.x - lensPos.x) * 0.22;
lensPos.y += (lensTarget.y - lensPos.y) * 0.22;
if (!hoverRegion && lineShown) updateCallout(); /* never leave the line hanging */
/* watchdog: if the line is somehow visible with no region and no running
retract, collapse it — a stranded line survives at most one frame */
if (!hoverRegion && !lineShown &&
!calloutClip.getAnimations().some(a => a.playState === "running") &&
!lineIsHidden()) {
retractLine();
}
layoutCallout();
drawMag();
drawLens();
drawWave();
drawSfxWave();
}
/* debug/scripting hook */
window.__hero = {
startSession, pauseSession, setGenre, setSfx, setScoreMuted, REGIONS, SFX, scoreGain,
drawWave, drawSfxWave, drawLens, sfxAnalyser,
get started() { return started; },
get lensPos() { return lensPos; },
get hoverRegion() { return hoverRegion; },
scoreEl, analyser, actx, video,
simulateHover(x, y) {
lensPos = { x, y }; lensTarget = { x, y };
inScene = true;
lens.classList.add("sai-sah-on");
const hit = REGIONS.find(r => x >= r.x && x <= r.x + r.w && y >= r.y && y <= r.y + r.h) || null;
setSfxForRegion(hit);
layoutCallout(); drawMag(); drawLens(); drawWave();
},
simulateLeave() {
inScene = false; hoverRegion = null;
lens.classList.remove("sai-sah-on");
setSfx(null); closeup.classList.remove("sai-sah-live");
updateCallout();
}
};
let vidTimeout = 100000;
const checkVideo = () => {
const vid = document.querySelector('#lp-hero .sqs-native-video video');
if(vid && vid.src !="" ) {
video = vid;
video.style.display = "";
scene.append(video);
/* composite mode: the still stays visible as the full-bleed base layer */
sceneSource = video;
vid.addEventListener('play', ()=>{
playRing.addEventListener("click", startSession);
muteBtn.addEventListener("click", () => setScoreMuted(!scoreMuted));
tick();
}, { once: true })
return;
}
if( vidTimeout > 0){
vidTimeout --;
setTimeout( checkVideo, 100 );
return;
}
document.querySelector('#lp-hero .content-wrapper').classList.add('lp-hero-static');
document.querySelector('#lp-hero .sai-sah-stage-wrap').style.display = "none";
};
checkVideo();
})();