feat(wall): FFT histogram + improved BPM detection + performance fixes
FFT Histogram: - Colorset-mapped frequency bars (bottom center) - Each bar corresponds to a TAO_SPECTRUM color - Colors pulse brighter based on FFT energy BPM Detection: - Lowered thresholds (0.03 kick, 0.08 flux) - Adaptive threshold tracks average energy - Faster detection (3 onsets vs 4) - More responsive smoothing (alpha 0.4) - 200ms debounce between beats Performance: - Canvas scale 2x (was 10x) - Limited 3D tilt ±12°/±10° - Optimized tiling margins - Wrapper-based overflow clipping Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
d339d56be6
commit
528e9e508c
@ -5,8 +5,35 @@
|
||||
<title>MAGIC·CHESS·360</title>
|
||||
<style>
|
||||
* { margin:0; padding:0; box-sizing:border-box; }
|
||||
html, body { width:100%; height:100%; overflow:hidden; background:#000; cursor:none; perspective:1200px; perspective-origin:50% 50%; }
|
||||
canvas { position:fixed; top:0; left:0; image-rendering:pixelated; image-rendering:crisp-edges; filter:saturate(1.3) brightness(1.1); transform-style:preserve-3d; transform-origin:50% 50%; will-change:transform; }
|
||||
html, body { width:100%; height:100%; overflow:hidden; background:#000; cursor:none; }
|
||||
#canvas-wrapper {
|
||||
position:fixed; top:0; left:0; width:100vw; height:100vh;
|
||||
overflow:hidden; perspective:1200px; perspective-origin:50% 50%;
|
||||
}
|
||||
canvas {
|
||||
position:absolute; top:50%; left:50%;
|
||||
image-rendering:pixelated; image-rendering:crisp-edges;
|
||||
filter:saturate(1.3) brightness(1.1);
|
||||
transform-style:preserve-3d; transform-origin:50% 50%;
|
||||
will-change:transform;
|
||||
}
|
||||
/* FFT HISTOGRAM - colorset mapped */
|
||||
#fft-hist {
|
||||
position:fixed; bottom:32px; left:50%; transform:translateX(-50%);
|
||||
width:60%; max-width:600px; height:40px;
|
||||
display:flex; gap:2px; align-items:flex-end;
|
||||
z-index:5; pointer-events:none;
|
||||
opacity:0.8;
|
||||
}
|
||||
.fft-bar {
|
||||
flex:1; min-width:4px; height:100%;
|
||||
border-radius:2px 2px 0 0;
|
||||
transform-origin:bottom;
|
||||
transition:transform 0.05s ease-out;
|
||||
}
|
||||
body.ui-hidden #fft-hist { opacity:0; }
|
||||
body.ui-visible #fft-hist { opacity:0.9; }
|
||||
|
||||
/* AUTO-HIDE UI */
|
||||
.ui-layer {
|
||||
transition: opacity 0.4s ease, transform 0.3s ease;
|
||||
@ -374,7 +401,7 @@ body.ui-visible #color-btn {
|
||||
<polygon points="9,4 9,12 16,8" fill="white"/>
|
||||
</svg>
|
||||
</a>
|
||||
<canvas id="cv"></canvas>
|
||||
<div id="canvas-wrapper"><canvas id="cv"></canvas></div>
|
||||
|
||||
<div id="controls">
|
||||
<!-- 2D Rotation/Perspective Joystick -->
|
||||
@ -412,6 +439,7 @@ body.ui-visible #color-btn {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="fft-hist"></div>
|
||||
<div id="bar">
|
||||
<span class="bl" id="bA">—</span>
|
||||
<span class="bl" style="color:rgba(255,255,255,.1)">+</span>
|
||||
@ -511,11 +539,12 @@ let bpmPhase = 0; // phase courante (ms depuis dernier beat)
|
||||
let bpmLastBeat= 0; // timestamp du dernier beat détecté
|
||||
let bpmClockT = 0; // temps du dernier tick horloge BPM
|
||||
|
||||
// Seuils onset
|
||||
const ONSET_KICK_THR = 0.08; // seuil relatif sur la bande kick
|
||||
const ONSET_KICK_FLUX = 0.25; // flux minimal
|
||||
const ONSET_MID_THR = 0.06;
|
||||
const ONSET_MID_FLUX = 0.20;
|
||||
// Seuils onset (lowered for better sensitivity)
|
||||
const ONSET_KICK_THR = 0.03; // seuil relatif sur la bande kick
|
||||
const ONSET_KICK_FLUX = 0.08; // flux minimal
|
||||
const ONSET_MID_THR = 0.03;
|
||||
const ONSET_MID_FLUX = 0.06;
|
||||
let adaptiveKickThr = 0.03; // adaptive threshold
|
||||
|
||||
// ── Corrections aux constantes BREATH ─────────────────────
|
||||
// On va multiplier BA1/BA2 dynamiquement depuis l'audio
|
||||
@ -560,6 +589,67 @@ function updateFFTBars(){
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// ── FFT COLORSET PULSE MODULATION ─────────────────────────
|
||||
let fftColorEnergy = new Array(16).fill(0); // Energy per colorset color
|
||||
let fftHistBars = [];
|
||||
|
||||
function buildFFTHist(){
|
||||
const el = document.getElementById('fft-hist');
|
||||
if(!el) return;
|
||||
el.innerHTML = '';
|
||||
fftHistBars = [];
|
||||
const N = TAO_SPECTRUM.length; // Match colorset size
|
||||
for(let i=0;i<N;i++){
|
||||
const bar = document.createElement('div');
|
||||
bar.className = 'fft-bar';
|
||||
const [r,g,b] = TAO_SPECTRUM[i];
|
||||
bar.style.background = `rgb(${r},${g},${b})`;
|
||||
bar.style.boxShadow = `0 0 8px rgba(${r},${g},${b},0.5)`;
|
||||
el.appendChild(bar);
|
||||
fftHistBars.push(bar);
|
||||
}
|
||||
}
|
||||
|
||||
function updateFFTHist(){
|
||||
if(!fftHistBars.length || !audioActive) return;
|
||||
const N = fftHistBars.length;
|
||||
const bins = AUDIO_BUF.length;
|
||||
|
||||
for(let i=0;i<N;i++){
|
||||
// Map each colorset color to a frequency band
|
||||
const t = i / N;
|
||||
const binStart = Math.floor(Math.pow(t, 1.4) * bins * 0.7);
|
||||
const binEnd = Math.floor(Math.pow((i+1)/N, 1.4) * bins * 0.7);
|
||||
|
||||
// Average energy in this band
|
||||
let energy = 0, count = 0;
|
||||
for(let b = binStart; b <= binEnd && b < bins; b++){
|
||||
energy += AUDIO_BUF[b];
|
||||
count++;
|
||||
}
|
||||
energy = count > 0 ? energy / count : 0;
|
||||
|
||||
// Smooth the energy
|
||||
fftColorEnergy[i] = fftColorEnergy[i] * 0.7 + energy * 0.3;
|
||||
const v = Math.min(1, fftColorEnergy[i] * 3);
|
||||
|
||||
// Update bar height and glow
|
||||
const [r,g,b] = TAO_SPECTRUM[i % TAO_SPECTRUM.length];
|
||||
const bright = Math.floor(50 + v * 50);
|
||||
fftHistBars[i].style.transform = `scaleY(${0.1 + v * 0.9})`;
|
||||
fftHistBars[i].style.background = `rgb(${Math.min(255,r+v*50)},${Math.min(255,g+v*50)},${Math.min(255,b+v*50)})`;
|
||||
fftHistBars[i].style.boxShadow = `0 0 ${8+v*15}px rgba(${r},${g},${b},${0.3+v*0.5})`;
|
||||
}
|
||||
}
|
||||
|
||||
// Rebuild histogram when colorset changes
|
||||
const _origSetColorset = setColorset;
|
||||
setColorset = function(name) {
|
||||
_origSetColorset(name);
|
||||
buildFFTHist();
|
||||
};
|
||||
|
||||
// ── Analyse audio — appelée dans rafLoop ───────────────────
|
||||
function analyseAudio(now){
|
||||
if(!audioActive || !analyser) return;
|
||||
@ -599,32 +689,40 @@ function analyseAudio(now){
|
||||
audKickPrev = kick;
|
||||
audMidPrev = mid;
|
||||
|
||||
// ── Adaptive threshold (tracks average energy) ────────────
|
||||
adaptiveKickThr = adaptiveKickThr * 0.995 + kick * 0.005;
|
||||
const dynamicThr = Math.max(ONSET_KICK_THR, adaptiveKickThr * 1.5);
|
||||
|
||||
// ── MAPPING 1 : KICK → PULSE brutal ──────────────────────
|
||||
if(kick > ONSET_KICK_THR && kickFlux > ONSET_KICK_FLUX){
|
||||
if(kick > dynamicThr && kickFlux > ONSET_KICK_FLUX){
|
||||
const amp = Math.min(0.6, kick * 2.5);
|
||||
// Dezoom brutal : signe aléatoire biaisé vers zoom
|
||||
const sign = Math.random() < 0.3 ? -1 : 1;
|
||||
pulse += sign * amp;
|
||||
pulse = Math.max(-0.45, Math.min(0.8, pulse));
|
||||
|
||||
// BPM: enregistrer onset
|
||||
bpmOnsets.push(now);
|
||||
// Purger les anciens
|
||||
while(bpmOnsets.length > 0 && now - bpmOnsets[0] > BPM_WIN)
|
||||
bpmOnsets.shift();
|
||||
// Recalculer BPM si >= 4 onsets
|
||||
if(bpmOnsets.length >= 4){
|
||||
const intervals = [];
|
||||
for(let i=1;i<bpmOnsets.length;i++)
|
||||
intervals.push(bpmOnsets[i]-bpmOnsets[i-1]);
|
||||
// Médiane
|
||||
intervals.sort((a,b)=>a-b);
|
||||
const med = intervals[Math.floor(intervals.length/2)];
|
||||
const bpmRaw = 60000/med;
|
||||
if(bpmRaw >= BPM_MIN && bpmRaw <= BPM_MAX){
|
||||
bpmEstimate = bpmEstimate*0.7 + bpmRaw*0.3;
|
||||
bpmInterval = 60000/bpmEstimate;
|
||||
bpmLastBeat = now;
|
||||
// BPM: enregistrer onset (minimum 200ms between onsets = max 300 BPM)
|
||||
if(bpmOnsets.length === 0 || now - bpmOnsets[bpmOnsets.length-1] > 200){
|
||||
bpmOnsets.push(now);
|
||||
// Purger les anciens
|
||||
while(bpmOnsets.length > 0 && now - bpmOnsets[0] > BPM_WIN)
|
||||
bpmOnsets.shift();
|
||||
// Recalculer BPM si >= 3 onsets (faster detection)
|
||||
if(bpmOnsets.length >= 3){
|
||||
const intervals = [];
|
||||
for(let i=1;i<bpmOnsets.length;i++)
|
||||
intervals.push(bpmOnsets[i]-bpmOnsets[i-1]);
|
||||
// Médiane
|
||||
intervals.sort((a,b)=>a-b);
|
||||
const med = intervals[Math.floor(intervals.length/2)];
|
||||
const bpmRaw = 60000/med;
|
||||
if(bpmRaw >= BPM_MIN && bpmRaw <= BPM_MAX){
|
||||
// More responsive smoothing
|
||||
const alpha = bpmEstimate === 0 ? 1.0 : 0.4;
|
||||
bpmEstimate = bpmEstimate*(1-alpha) + bpmRaw*alpha;
|
||||
bpmInterval = 60000/bpmEstimate;
|
||||
bpmLastBeat = now;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -666,6 +764,7 @@ function analyseAudio(now){
|
||||
}
|
||||
|
||||
updateFFTBars();
|
||||
updateFFTHist();
|
||||
|
||||
// ── HUD audio ─────────────────────────────────────────────
|
||||
const bpmStr = bpmEstimate > 0 ? Math.round(bpmEstimate)+'BPM' : '—BPM';
|
||||
@ -1043,10 +1142,10 @@ function updateKnobPosition() {
|
||||
// Apply to perspective
|
||||
perspAngleX = joystickX * 0.3; // ±0.3 radians
|
||||
perspAngleY = joystickY * 0.3;
|
||||
// Apply 3D CSS perspective transform to canvas
|
||||
// Apply 3D CSS perspective transform to canvas (limited for 2x coverage)
|
||||
const cv = document.getElementById('cv');
|
||||
const rotY = joystickX * 45; // ±45 degrees horizontal tilt
|
||||
const rotX = -joystickY * 35; // ±35 degrees vertical tilt (inverted for natural feel)
|
||||
const rotY = joystickX * 12; // ±12 degrees horizontal (safe for 2x)
|
||||
const rotX = -joystickY * 10; // ±10 degrees vertical (safe for 2x)
|
||||
cv.style.transform = 'rotateX(' + rotX + 'deg) rotateY(' + rotY + 'deg)';
|
||||
}
|
||||
|
||||
@ -1259,7 +1358,7 @@ function rafLoop(now){
|
||||
|
||||
const cx=W/2+panX+glitchX;
|
||||
const cy=H/2+panY+glitchY;
|
||||
const margin=motifPx*4; // Extended for 3D rotation coverage
|
||||
const margin=motifPx*3; // Balanced margin for 2x canvas
|
||||
|
||||
// Origines de tiling (espace non-transformé)
|
||||
// On commence assez à gauche/haut pour couvrir après scale
|
||||
@ -1389,7 +1488,15 @@ function applySpeed(){
|
||||
if(playing) schedule();
|
||||
}
|
||||
function cycleSize(){ cellIdx=(cellIdx+1)%CELL_SIZES.length; applySize(); renderOff(); }
|
||||
function resize(){ cv.width=window.innerWidth; cv.height=window.innerHeight; }
|
||||
function resize(){
|
||||
// Oversize canvas 2x - balanced for performance + coverage
|
||||
const scale = 2;
|
||||
cv.width = Math.ceil(window.innerWidth * scale);
|
||||
cv.height = Math.ceil(window.innerHeight * scale);
|
||||
// Center the oversized canvas in wrapper
|
||||
cv.style.marginLeft = -cv.width/2 + 'px';
|
||||
cv.style.marginTop = -cv.height/2 + 'px';
|
||||
}
|
||||
|
||||
document.addEventListener('mousemove',e=>{
|
||||
mx=e.clientX/window.innerWidth;
|
||||
@ -1414,7 +1521,7 @@ serB=pickSer(famB);
|
||||
showA=true; showB=false;
|
||||
phase='soloA'; phaseStep=0;
|
||||
|
||||
applySize(); renderOff(); resize(); updateBar(); buildFFTBars();
|
||||
applySize(); renderOff(); resize(); updateBar(); buildFFTBars(); buildFFTHist();
|
||||
lastT=performance.now();
|
||||
rafId=requestAnimationFrame(rafLoop);
|
||||
startAnim();
|
||||
|
||||
Loading…
Reference in New Issue
Block a user