390 lines
16 KiB
JavaScript
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();
|
|
}
|
|
})();
|