Last active
December 16, 2025 11:54
-
-
Save selalipop/81c6a50b57c41e63cba0ec0abfdfed68 to your computer and use it in GitHub Desktop.
Run in dev console while visiting npmjs.com/login to make the wombat giggle to the beat: click an EQ band to follow it, and adjust thresholds to set sensitivity
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| (async function bassSelector() { | |
| const bands = [ | |
| { name: 'Sub Bass', freq: 40, range: 20, color: '#ff0055' }, | |
| { name: 'Low Bass', freq: 80, range: 20, color: '#ff5500' }, | |
| { name: 'Mid Bass', freq: 120, range: 20, color: '#ffaa00' }, | |
| { name: 'Upper Bass', freq: 160, range: 20, color: '#aaff00' }, | |
| { name: 'Low Mid', freq: 250, range: 50, color: '#00ff55' }, | |
| { name: 'Mid', freq: 400, range: 100, color: '#00ffaa' }, | |
| { name: 'Upper Mid', freq: 800, range: 200, color: '#00aaff' }, | |
| { name: 'Kick Punch', freq: 100, range: 30, color: '#aa00ff' }, | |
| ]; | |
| const config = { | |
| selectedBand: 1, | |
| spikeThreshold: 30, | |
| cooldownMs: 100, | |
| averageWindow: 15, | |
| ariaLabelMatch: "wombat visibly giggle" | |
| }; | |
| // Disable all audio processing to preserve dynamics for spike detection | |
| const stream = await navigator.mediaDevices.getUserMedia({ | |
| audio: { | |
| echoCancellation: false, | |
| noiseSuppression: false, | |
| autoGainControl: false, | |
| } | |
| }); | |
| const audioContext = new AudioContext(); | |
| const source = audioContext.createMediaStreamSource(stream); | |
| const analyser = audioContext.createAnalyser(); | |
| analyser.fftSize = 4096; | |
| analyser.smoothingTimeConstant = 0.4; | |
| source.connect(analyser); | |
| const bufferLength = analyser.frequencyBinCount; | |
| const dataArray = new Uint8Array(bufferLength); | |
| const frequencyResolution = audioContext.sampleRate / analyser.fftSize; | |
| bands.forEach(band => { | |
| band.minBin = Math.floor((band.freq - band.range) / frequencyResolution); | |
| band.maxBin = Math.ceil((band.freq + band.range) / frequencyResolution); | |
| band.history = []; | |
| band.peakSpike = 0; | |
| }); | |
| // Peak picking state machine | |
| const peakPicker = { | |
| state: 'waiting', // waiting | rising | triggered | |
| previousSpike: 0, | |
| process(spike, threshold) { | |
| let shouldFire = false; | |
| if (this.state === 'waiting' && spike > threshold) { | |
| this.state = 'rising'; | |
| } else if (this.state === 'rising') { | |
| if (spike < this.previousSpike) { | |
| // Just passed the peak - fire! | |
| shouldFire = true; | |
| this.state = 'triggered'; | |
| } | |
| } else if (this.state === 'triggered' && spike < threshold * 0.5) { | |
| // Fallen back down, ready for next hit | |
| this.state = 'waiting'; | |
| } | |
| this.previousSpike = spike; | |
| return shouldFire; | |
| } | |
| }; | |
| // Robust click mechanism | |
| const clicker = { | |
| lastClickTime: 0, | |
| isClickPending: false, | |
| triggerClick() { | |
| const now = Date.now(); | |
| if (this.isClickPending || now - this.lastClickTime < config.cooldownMs) { | |
| return false; | |
| } | |
| const button = document.querySelector(`[aria-label*="${config.ariaLabelMatch}"]`); | |
| if (!button) return false; | |
| this.isClickPending = true; | |
| this.lastClickTime = now; | |
| const rect = button.getBoundingClientRect(); | |
| const x = rect.left + rect.width / 2; | |
| const y = rect.top + rect.height / 2; | |
| const eventProps = { | |
| bubbles: true, | |
| cancelable: true, | |
| view: window, | |
| clientX: x, | |
| clientY: y | |
| }; | |
| button.dispatchEvent(new PointerEvent('pointerdown', eventProps)); | |
| button.dispatchEvent(new MouseEvent('mousedown', eventProps)); | |
| button.dispatchEvent(new PointerEvent('pointerup', eventProps)); | |
| button.dispatchEvent(new MouseEvent('mouseup', eventProps)); | |
| button.dispatchEvent(new MouseEvent('click', eventProps)); | |
| this.isClickPending = false; | |
| return true; | |
| } | |
| }; | |
| const debugDiv = document.createElement('div'); | |
| debugDiv.id = 'bass-debugger'; | |
| debugDiv.innerHTML = ` | |
| <style> | |
| #bass-debugger * { box-sizing: border-box; } | |
| .bd-band { | |
| padding: 8px; | |
| margin: 4px 0; | |
| border-radius: 6px; | |
| cursor: pointer; | |
| border: 2px solid transparent; | |
| transition: border-color 0.15s, background 0.15s; | |
| } | |
| .bd-band:hover { background: #333; } | |
| .bd-band.selected { border-color: #0ff; background: #1a3a4a; } | |
| .bd-meters { display: flex; gap: 4px; margin: 4px 0; height: 20px; } | |
| .bd-meter-bg { background: #333; height: 100%; border-radius: 3px; overflow: hidden; flex: 1; position: relative; } | |
| .bd-meter-fill { height: 100%; transition: width 0.05s; position: absolute; left: 0; top: 0; } | |
| .bd-avg-line { position: absolute; top: 0; bottom: 0; width: 2px; background: #fff; opacity: 0.7; } | |
| .bd-spike-indicator { | |
| position: absolute; right: 4px; top: 50%; transform: translateY(-50%); | |
| font-size: 12px; opacity: 0; transition: opacity 0.1s; | |
| } | |
| .bd-spike-indicator.flash { opacity: 1; } | |
| .bd-band-header { display: flex; justify-content: space-between; align-items: center; } | |
| .bd-band-name { font-weight: bold; } | |
| .bd-band-info { font-size: 10px; color: #888; } | |
| .bd-level { font-size: 10px; color: #aaa; display: flex; justify-content: space-between; } | |
| </style> | |
| <div style="position:fixed;top:10px;right:10px;background:#1a1a2e;color:#eee;padding:15px;border-radius:10px;font-family:monospace;font-size:12px;z-index:999999;width:340px;max-height:90vh;overflow-y:auto;box-shadow:0 4px 25px rgba(0,0,0,0.6);"> | |
| <div style="font-weight:bold;margin-bottom:5px;color:#0ff;font-size:14px;">🎵 Spike Detector</div> | |
| <div style="font-size:10px;color:#888;margin-bottom:10px;">Peak picking • Fires at top of each spike</div> | |
| <div id="bd-bands"></div> | |
| <div style="margin-top:15px;padding-top:10px;border-top:1px solid #333;"> | |
| <div style="margin-bottom:8px;"> | |
| <label style="color:#888;">Spike Threshold: <span id="bd-threshold-val">${config.spikeThreshold}</span></label> | |
| <input type="range" id="bd-threshold" min="5" max="100" value="${config.spikeThreshold}" style="width:100%;margin-top:4px;"> | |
| </div> | |
| <div style="margin-bottom:8px;"> | |
| <label style="color:#888;">Average Window: <span id="bd-window-val">${config.averageWindow}</span> frames</label> | |
| <input type="range" id="bd-window" min="5" max="60" value="${config.averageWindow}" style="width:100%;margin-top:4px;"> | |
| </div> | |
| <div>Button: <span id="bd-button">checking...</span></div> | |
| <div>Status: <span id="bd-status" style="color:#0f0;">Listening...</span></div> | |
| </div> | |
| <div style="margin-top:10px;font-size:10px;color:#666;"> | |
| stopBassClicker() to close | |
| </div> | |
| </div> | |
| `; | |
| document.body.appendChild(debugDiv); | |
| const bandsContainer = document.getElementById('bd-bands'); | |
| bands.forEach((band, i) => { | |
| const bandEl = document.createElement('div'); | |
| bandEl.className = 'bd-band' + (i === config.selectedBand ? ' selected' : ''); | |
| bandEl.dataset.index = i; | |
| bandEl.innerHTML = ` | |
| <div class="bd-band-header"> | |
| <span class="bd-band-name" style="color:${band.color}">${band.name}</span> | |
| <span class="bd-band-info">${band.freq}Hz ±${band.range}</span> | |
| </div> | |
| <div class="bd-meters"> | |
| <div class="bd-meter-bg" title="Level vs Average"> | |
| <div class="bd-meter-fill" id="bd-meter-${i}" style="background:${band.color};width:0%;opacity:0.7;"></div> | |
| <div class="bd-avg-line" id="bd-avg-${i}" style="left:0%;"></div> | |
| <span class="bd-spike-indicator" id="bd-flash-${i}">⚡</span> | |
| </div> | |
| <div class="bd-meter-bg" title="Spike (above avg)" style="flex:0.5;"> | |
| <div class="bd-meter-fill" id="bd-spike-${i}" style="background:#fff;width:0%;"></div> | |
| </div> | |
| </div> | |
| <div class="bd-level"> | |
| <span>Now: <span id="bd-level-${i}">0</span></span> | |
| <span>Avg: <span id="bd-avg-val-${i}">0</span></span> | |
| <span>Δ: <span id="bd-delta-${i}">0</span></span> | |
| </div> | |
| `; | |
| bandEl.addEventListener('click', () => { | |
| document.querySelectorAll('.bd-band').forEach(el => el.classList.remove('selected')); | |
| bandEl.classList.add('selected'); | |
| config.selectedBand = i; | |
| // Reset peak picker when switching bands | |
| peakPicker.state = 'waiting'; | |
| peakPicker.previousSpike = 0; | |
| console.log(`Selected: ${band.name} (${band.freq}Hz)`); | |
| }); | |
| bandsContainer.appendChild(bandEl); | |
| }); | |
| document.getElementById('bd-threshold').addEventListener('input', (e) => { | |
| config.spikeThreshold = parseInt(e.target.value); | |
| document.getElementById('bd-threshold-val').textContent = config.spikeThreshold; | |
| }); | |
| document.getElementById('bd-window').addEventListener('input', (e) => { | |
| config.averageWindow = parseInt(e.target.value); | |
| document.getElementById('bd-window-val').textContent = config.averageWindow; | |
| }); | |
| const buttonEl = document.getElementById('bd-button'); | |
| const statusEl = document.getElementById('bd-status'); | |
| let isRunning = true; | |
| function getLevel(band) { | |
| let sum = 0; | |
| for (let bin = band.minBin; bin <= band.maxBin && bin < bufferLength; bin++) { | |
| sum += dataArray[bin]; | |
| } | |
| return sum / (band.maxBin - band.minBin + 1); | |
| } | |
| function analyze() { | |
| if (!isRunning) return; | |
| if (audioContext.state === 'suspended') { | |
| audioContext.resume(); | |
| } | |
| analyser.getByteFrequencyData(dataArray); | |
| bands.forEach((band, i) => { | |
| const level = getLevel(band); | |
| // Calculate average from PREVIOUS frames only (before adding current) | |
| const avg = band.history.length > 0 | |
| ? band.history.reduce((a, b) => a + b, 0) / band.history.length | |
| : level; | |
| const spike = Math.max(0, level - avg); | |
| // Now add current level to history for next frame | |
| band.history.push(level); | |
| if (band.history.length > config.averageWindow) { | |
| band.history.shift(); | |
| } | |
| if (spike > band.peakSpike) band.peakSpike = spike; | |
| document.getElementById(`bd-meter-${i}`).style.width = (level / 255 * 100) + '%'; | |
| document.getElementById(`bd-avg-${i}`).style.left = (avg / 255 * 100) + '%'; | |
| document.getElementById(`bd-spike-${i}`).style.width = Math.min(100, spike / 80 * 100) + '%'; | |
| document.getElementById(`bd-level-${i}`).textContent = level.toFixed(0); | |
| document.getElementById(`bd-avg-val-${i}`).textContent = avg.toFixed(0); | |
| document.getElementById(`bd-delta-${i}`).textContent = spike.toFixed(0); | |
| const flashEl = document.getElementById(`bd-flash-${i}`); | |
| if (spike > config.spikeThreshold) { | |
| flashEl.classList.add('flash'); | |
| setTimeout(() => flashEl.classList.remove('flash'), 150); | |
| } | |
| }); | |
| const selected = bands[config.selectedBand]; | |
| const level = getLevel(selected); | |
| // Use the already-computed average from previous frames | |
| const avg = selected.history.length > 1 | |
| ? (selected.history.slice(0, -1).reduce((a, b) => a + b, 0) / (selected.history.length - 1)) | |
| : level; | |
| const spike = Math.max(0, level - avg); | |
| const button = document.querySelector(`[aria-label*="${config.ariaLabelMatch}"]`); | |
| buttonEl.textContent = button ? '✅ Found' : '❌ Not found'; | |
| buttonEl.style.color = button ? '#0f0' : '#f00'; | |
| // Use peak picker instead of simple threshold | |
| const shouldFire = peakPicker.process(spike, config.spikeThreshold); | |
| if (shouldFire) { | |
| const clicked = clicker.triggerClick(); | |
| if (clicked) { | |
| statusEl.textContent = `⚡ PEAK! (+${spike.toFixed(0)})`; | |
| statusEl.style.color = '#0ff'; | |
| setTimeout(() => { | |
| statusEl.textContent = 'Listening...'; | |
| statusEl.style.color = '#0f0'; | |
| }, 300); | |
| } | |
| } | |
| requestAnimationFrame(analyze); | |
| } | |
| analyze(); | |
| window.bassConfig = config; | |
| window.bassBands = bands; | |
| window.peakPicker = peakPicker; // Expose for debugging | |
| window.stopBassClicker = () => { | |
| isRunning = false; | |
| stream.getTracks().forEach(track => track.stop()); | |
| audioContext.close(); | |
| document.getElementById('bass-debugger')?.remove(); | |
| console.log("🛑 Stopped"); | |
| }; | |
| console.log("Spike detector ready! (with peak picking, raw audio)"); | |
| })(); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment