YrXtals/web/yr_crystals_embed.js

390 lines
16 KiB
JavaScript

// visualizer embed for else-if.org/yr_xtals.
(function () {
const HOST = 'https://files.else-if.org/f/YrXtals/';
const VIS_MODULE = HOST + 'yr_crystals_web.js';
const ALBUM_FOLDER = HOST + 'Knives_For_Cutting_Corners/';
const ICON = {
play: HOST + 'assets/Play.svg',
pause: HOST + 'assets/Pause.svg',
bskip: HOST + 'assets/BSkip.svg',
fskip: HOST + 'assets/FSkip.svg',
mute: HOST + 'assets/Mute.svg',
unmute: HOST + 'assets/Unmute.svg',
};
const ANCHOR_SELECTOR = 'yrxtals';
const TRACKS_HOST_SELECTOR = 'yrxtals-tracks';
const ASPECT_W = 16;
const ASPECT_H = 9;
const NUM_BINS = 26;
const DEFAULT_FFT = 16384;
const DEFAULT_HOP = 4096;
const TRACK_PARAMS = {
'bouncy castle': { fft: 16384, hop: 2048 },
'curled': { fft: 8192, hop: 2048 },
'eeger': { fft: 16384, hop: 2048 },
'fickle': { fft: 8192, hop: 2048 },
'fire sale': { fft: 16384, hop: 2048 },
'friik': { fft: 16384, hop: 2048 },
'gourded': { fft: 16384, hop: 2048 },
'moron': { fft: 16384, hop: 2048 },
'never give an angel a front': { fft: 8192, hop: 4096 },
'now youre speaking my language': { fft: 16384, hop: 2048 },
'ornery': { fft: 16384, hop: 2048 },
'quicksand': { fft: 16384, hop: 2048 },
'stolen art': { fft: 8192, hop: 2048 },
'them bunch': { fft: 8192, hop: 2048 },
'twig': { fft: 16384, hop: 2048 },
'we that borrowed': { fft: 16384, hop: 2048 },
};
const LOG_MIN = Math.log10(40);
const LOG_MAX = Math.log10(11000);
const CTRL_FADE_DELAY_MS = 1500;
const CTRL_FADE_DURATION_MS = 400;
const RESTART_THRESHOLD_S = 3;
function isMobileOrTablet() {
if (typeof navigator === 'undefined') return false;
const ua = navigator.userAgent || '';
if (/Mobi|Android|iPhone|iPod|BlackBerry|IEMobile|Opera Mini|webOS/i.test(ua)) return true;
if (/Macintosh/i.test(ua) && navigator.maxTouchPoints > 1) return true;
return false;
}
// builds an img element for one of the asset URLs.
function iconImg(src) {
const img = document.createElement('img');
img.src = src;
img.draggable = false;
return img;
}
function start() {
const anchor = document.querySelector(ANCHOR_SELECTOR);
if (!anchor) return;
swap(anchor);
}
function swap(anchor) {
const mobile = isMobileOrTablet();
const wrap = document.createElement('div');
wrap.className = 'viz-wrap ' + (mobile ? 'viz-mobile' : 'viz-desktop');
const canvasWrap = document.createElement('div');
canvasWrap.className = 'viz-canvas-wrap';
const canvas = document.createElement('canvas');
let externalTracksHost = document.querySelector(TRACKS_HOST_SELECTOR);
if (externalTracksHost && !mobile) {
externalTracksHost.remove();
externalTracksHost = null;
}
const tracksEl = externalTracksHost || document.createElement('div');
if (!externalTracksHost) {
tracksEl.className = 'viz-tracks';
}
const volBtn = document.createElement('button');
volBtn.className = 'viz-ctrl viz-vol initial';
volBtn.setAttribute('aria-label', 'unmute');
volBtn.appendChild(iconImg(ICON.mute));
const promptText = document.createElement('span');
promptText.className = 'viz-prompt';
promptText.textContent = 'Unmute the audio to begin the visualizer to my music!';
volBtn.appendChild(promptText);
const transportEl = document.createElement('div');
transportEl.className = 'viz-transport';
const backBtn = document.createElement('button');
backBtn.className = 'viz-ctrl';
backBtn.setAttribute('aria-label', 'previous track');
backBtn.appendChild(iconImg(ICON.bskip));
const playBtn = document.createElement('button');
playBtn.className = 'viz-ctrl viz-ctrl-play';
playBtn.setAttribute('aria-label', 'play');
playBtn.appendChild(iconImg(ICON.play));
const fwdBtn = document.createElement('button');
fwdBtn.className = 'viz-ctrl';
fwdBtn.setAttribute('aria-label', 'next track');
fwdBtn.appendChild(iconImg(ICON.fskip));
transportEl.appendChild(backBtn);
transportEl.appendChild(playBtn);
transportEl.appendChild(fwdBtn);
canvasWrap.appendChild(canvas);
canvasWrap.appendChild(volBtn);
canvasWrap.appendChild(transportEl);
wrap.appendChild(canvasWrap);
if (!externalTracksHost) {
wrap.appendChild(tracksEl);
}
anchor.replaceWith(wrap);
let vizRef = null;
const fit = () => {
const r = canvas.getBoundingClientRect();
const dpr = window.devicePixelRatio || 1;
const w = Math.max(1, Math.floor(r.width * dpr));
const h = Math.max(1, Math.floor(r.height * dpr));
canvas.width = w;
canvas.height = h;
if (vizRef && vizRef.resize) vizRef.resize(w, h);
};
fit();
new ResizeObserver(fit).observe(canvas);
window.addEventListener('resize', fit);
window.addEventListener('orientationchange', fit);
import(VIS_MODULE)
.then(mod => mod.mount(canvas))
.then(viz => {
vizRef = viz;
fit();
bootAudio({ tracksEl, volBtn, transportEl, backBtn, playBtn, fwdBtn }, viz);
})
.catch(err => console.error('[yrxtls] mount failed:', err));
}
async function bootAudio(ui, viz) {
let html;
try {
html = await (await fetch(ALBUM_FOLDER)).text();
} catch (e) {
console.error('[yrxtls] folder fetch failed:', e);
return;
}
const doc = new DOMParser().parseFromString(html, 'text/html');
const cards = [...doc.querySelectorAll('.folder-card-wrap[data-url$=".mp3"]')];
if (cards.length === 0) return;
const tracks = cards
.map(c => {
const url = c.dataset.url;
const stem = decodeURIComponent(url.split('/').pop().replace(/\.mp3$/i, ''));
return { url, name: stem.replace(/[_-]+/g, ' ') };
})
.sort((a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: 'base' }));
const audio = new Audio();
audio.crossOrigin = 'anonymous';
audio.preload = 'auto';
audio.muted = true;
// log-spaced band edges in Hz.
const binEdges = new Float32Array(NUM_BINS + 1);
for (let i = 0; i <= NUM_BINS; i++) {
const t = i / NUM_BINS;
binEdges[i] = Math.pow(10, LOG_MIN + (LOG_MAX - LOG_MIN) * t);
}
const outBins = new Float32Array(NUM_BINS);
let audioCtx = null;
let srcNode = null;
let analyser = null;
let gainNode = null;
let fftBuf = null;
let isMuted = true;
let lastSampleTime = -1;
let currentIndex = -1;
let currentHop = DEFAULT_HOP;
// idempotently builds the analyser plus gain graph against the media element.
function ensureGraph() {
if (audioCtx) return;
const Ctor = window.AudioContext || window.webkitAudioContext;
if (!Ctor) return;
audioCtx = new Ctor();
srcNode = audioCtx.createMediaElementSource(audio);
analyser = audioCtx.createAnalyser();
analyser.fftSize = DEFAULT_FFT;
analyser.smoothingTimeConstant = 0.2;
gainNode = audioCtx.createGain();
gainNode.gain.value = isMuted ? 0 : 1;
srcNode.connect(analyser);
analyser.connect(gainNode);
gainNode.connect(audioCtx.destination);
audio.muted = false;
fftBuf = new Float32Array(analyser.frequencyBinCount);
if (audioCtx.state === 'suspended') audioCtx.resume();
requestAnimationFrame(pumpFrames);
}
// rAF-driven FFT pump gated to one read per currentHop interval.
function pumpFrames() {
if (!analyser) return;
const now = audioCtx.currentTime;
const hopSec = currentHop / audioCtx.sampleRate;
if (lastSampleTime < 0 || now - lastSampleTime >= hopSec) {
lastSampleTime = now;
analyser.getFloatFrequencyData(fftBuf);
const binHz = audioCtx.sampleRate / analyser.fftSize;
for (let b = 0; b < NUM_BINS; b++) {
const loIdx = Math.max(0, Math.floor(binEdges[b] / binHz));
const hiIdx = Math.min(fftBuf.length - 1, Math.ceil(binEdges[b + 1] / binHz));
let peak = -200;
for (let k = loIdx; k <= hiIdx; k++) {
if (fftBuf[k] > peak) peak = fftBuf[k];
}
outBins[b] = Math.max(-80, Math.min(0, peak));
}
if (viz && viz.pushBins) viz.pushBins(outBins);
}
requestAnimationFrame(pumpFrames);
}
const pills = [];
tracks.forEach((track, idx) => {
const btn = document.createElement('button');
btn.className = 'viz-track';
btn.textContent = track.name;
btn.addEventListener('click', () => {
if (currentIndex === idx && !audio.paused) {
audio.pause();
return;
}
loadAndPlay(idx);
});
pills.push(btn);
ui.tracksEl.appendChild(btn);
});
// applies the per-track FFT and hop override, falling back to defaults on no match.
function normalizeTrackName(s) {
return (s || '').toLowerCase().replace(/['"]/g, '').replace(/[_\-\s]+/g, ' ').trim();
}
const TRACK_PARAMS_NORM = {};
for (const k of Object.keys(TRACK_PARAMS)) {
TRACK_PARAMS_NORM[normalizeTrackName(k)] = TRACK_PARAMS[k];
}
function applyTrackParams(name) {
const key = normalizeTrackName(name);
const hit = TRACK_PARAMS_NORM[key];
const params = hit || { fft: DEFAULT_FFT, hop: DEFAULT_HOP };
console.log('[yrxtls] track', JSON.stringify(name), 'key', JSON.stringify(key), 'params', params, hit ? '(override)' : '(default)');
currentHop = params.hop;
if (analyser && srcNode && gainNode && analyser.fftSize !== params.fft) {
const smoothing = analyser.smoothingTimeConstant;
try { srcNode.disconnect(analyser); } catch (e) {}
try { analyser.disconnect(gainNode); } catch (e) {}
analyser = audioCtx.createAnalyser();
analyser.fftSize = params.fft;
analyser.smoothingTimeConstant = smoothing;
srcNode.connect(analyser);
analyser.connect(gainNode);
fftBuf = new Float32Array(analyser.frequencyBinCount);
console.log('[yrxtls] analyser recreated at fftSize', analyser.fftSize, 'bins', analyser.frequencyBinCount);
}
}
// starts playback of the indexed track with wrap-around on out-of-range index.
function loadAndPlay(idx) {
if (tracks.length === 0) return;
const wrapped = ((idx % tracks.length) + tracks.length) % tracks.length;
currentIndex = wrapped;
pills.forEach((p, i) => p.classList.toggle('active', i === wrapped));
const active = pills[wrapped];
if (active && active.scrollIntoView) {
active.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
}
audio.src = tracks[wrapped].url;
ensureGraph();
applyTrackParams(tracks[wrapped].name);
audio.play().catch(err => console.error('[yrxtls] play failed:', err));
}
function updatePlayIcon() {
const playing = !!audio.src && !audio.paused;
ui.playBtn.firstChild.src = playing ? ICON.pause : ICON.play;
ui.playBtn.setAttribute('aria-label', playing ? 'pause' : 'play');
}
ui.backBtn.addEventListener('click', () => {
if (currentIndex < 0) { loadAndPlay(0); return; }
if (!audio.paused && audio.currentTime > RESTART_THRESHOLD_S) {
audio.currentTime = 0;
} else {
loadAndPlay(currentIndex - 1);
}
});
ui.fwdBtn.addEventListener('click', () => {
loadAndPlay(currentIndex < 0 ? 0 : currentIndex + 1);
});
ui.playBtn.addEventListener('click', () => {
if (currentIndex < 0) { loadAndPlay(0); return; }
if (audio.paused) {
ensureGraph();
audio.play().catch(err => console.error('[yrxtls] play failed:', err));
} else {
audio.pause();
}
});
audio.addEventListener('play', updatePlayIcon);
audio.addEventListener('pause', updatePlayIcon);
audio.addEventListener('ended', () => {
if (currentIndex >= 0 && currentIndex < tracks.length - 1) {
loadAndPlay(currentIndex + 1);
} else {
pills.forEach(p => p.classList.remove('active'));
currentIndex = -1;
updatePlayIcon();
}
});
let fadeTimer = null;
// reveals overlay controls and arms the idle-fade timer.
function showControls() {
ui.volBtn.classList.remove('fade');
ui.transportEl.classList.remove('fade');
if (fadeTimer) { clearTimeout(fadeTimer); fadeTimer = null; }
fadeTimer = setTimeout(() => {
ui.transportEl.classList.add('fade');
if (!isMuted) ui.volBtn.classList.add('fade');
}, CTRL_FADE_DELAY_MS);
}
function setMutedState(muted) {
isMuted = muted;
const iconEl = ui.volBtn.querySelector('img');
if (iconEl) iconEl.src = muted ? ICON.mute : ICON.unmute;
ui.volBtn.setAttribute('aria-label', muted ? 'unmute' : 'mute');
if (gainNode) gainNode.gain.value = muted ? 0 : 1;
showControls();
}
// resolves the seed track index by name match, falling back to alphabetical index zero.
function seedTrackIndex() {
const target = 'it caves in';
const idx = tracks.findIndex(t => t.name.trim().toLowerCase() === target);
return idx >= 0 ? idx : 0;
}
ui.volBtn.addEventListener('click', () => {
const wasInitial = ui.volBtn.classList.contains('initial');
if (wasInitial) {
ui.volBtn.classList.remove('initial');
loadAndPlay(seedTrackIndex());
ensureGraph();
setMutedState(false);
return;
}
if (!audio.src && tracks.length > 0) {
loadAndPlay(0);
}
ensureGraph();
setMutedState(!isMuted);
});
document.addEventListener('mousemove', showControls);
document.addEventListener('touchstart', showControls, { passive: true });
showControls();
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', start);
} else {
start();
}
})();