Skip to content

Instantly share code, notes, and snippets.

@aldoyh
Created August 30, 2026 17:36
Show Gist options
  • Select an option

  • Save aldoyh/af7c14f069d452c33a590cb0ef375543 to your computer and use it in GitHub Desktop.

Select an option

Save aldoyh/af7c14f069d452c33a590cb0ef375543 to your computer and use it in GitHub Desktop.
Wave Surfer Audio Visualizer Studio
<!DOCTYPE html>
<html lang="en" class="dark">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<title>Wave Surfer Audio Visualizer Studio Pro</title>
<!-- Fonts -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;600;800&display=swap" rel="stylesheet">
<!-- Icons (Phosphor) for clean, geometric look similar to reference -->
<script src="https://unpkg.com/@phosphor-icons/web"></script>
<!-- HLS for Radio Stream -->
<script src="https://cdn.jsdelivr.net/npm/hls.js@latest"></script>
<!-- Tailwind CSS -->
<script src="https://cdn.tailwindcss.com"></script>
<script>
tailwind.config = {
darkMode: 'class',
theme: {
extend: {
fontFamily: {
sans: ['Outfit', 'sans-serif'],
},
colors: {
cosmic: {
base: '#050014',
glow: '#2a0a4a'
}
},
backgroundImage: {
'orb-gradient': 'linear-gradient(160deg, #3b28cc 0%, #d414c9 100%)',
'active-glow': 'linear-gradient(135deg, #4f46e5, #ec4899)'
}
}
}
}
</script>
<style>
body {
background-color: #050014;
color: #ffffff;
overflow: hidden;
touch-action: none; /* Prevent scroll on touch */
}
/* Deep Space Starry Background */
.stars-bg {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
z-index: 0;
background-image:
radial-gradient(circle at 50% 50%, rgba(42, 10, 74, 0.4) 0%, transparent 60%),
url("data:image/svg+xml,%3Csvg width='200' height='200' xmlns='http://www.w3.org/2000/svg'%3E%3Ccircle cx='40' cy='20' r='1' fill='%23ffffff' fill-opacity='0.2'/%3E%3Ccircle cx='150' cy='80' r='1.5' fill='%23ffffff' fill-opacity='0.4'/%3E%3Ccircle cx='90' cy='160' r='0.8' fill='%23ffffff' fill-opacity='0.3'/%3E%3Ccircle cx='180' cy='190' r='1.2' fill='%23ffffff' fill-opacity='0.15'/%3E%3Ccircle cx='10' cy='110' r='1' fill='%23ffffff' fill-opacity='0.25'/%3E%3C/svg%3E");
background-size: cover, 150px 150px;
pointer-events: none;
}
/* Visualizer Canvas - Rendered behind UI */
#visualizer-canvas {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
z-index: 5;
pointer-events: none;
/* Slight blur for neon dispersion effect */
filter: drop-shadow(0 0 10px rgba(212, 20, 201, 0.3));
}
/* Central Power Orb */
.power-orb-wrapper {
position: relative;
width: 280px;
height: 280px;
border-radius: 50%;
/* Outer thin ring */
border: 1px solid rgba(255, 255, 255, 0.15);
display: flex;
align-items: center;
justify-content: center;
z-index: 20;
}
.power-orb {
width: 250px;
height: 250px;
border-radius: 50%;
background: var(--tw-gradient-stops);
background-image: linear-gradient(160deg, #3b28cc 0%, #d414c9 100%);
box-shadow:
0 0 80px rgba(212, 20, 201, 0.3),
inset 0 -10px 30px rgba(0, 0, 0, 0.2),
inset 0 10px 30px rgba(255, 255, 255, 0.2);
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: transform 0.2s cubic-bezier(0.175, 0.885, 0.32, 1.275), box-shadow 0.3s ease;
}
.power-orb:active {
transform: scale(0.95);
}
.power-orb.is-playing {
box-shadow:
0 0 120px rgba(212, 20, 201, 0.6),
inset 0 -10px 30px rgba(0, 0, 0, 0.2),
inset 0 10px 30px rgba(255, 255, 255, 0.4);
animation: pulseOrb 2s infinite alternate;
}
@keyframes pulseOrb {
0% { transform: scale(1); }
100% { transform: scale(1.02); }
}
.power-icon {
font-size: 80px;
color: #ffffff;
font-weight: 300;
filter: drop-shadow(0 0 10px rgba(255,255,255,0.5));
transition: text-shadow 0.3s ease;
}
.power-orb.is-playing .power-icon {
text-shadow: 0 0 20px #fff, 0 0 40px #d414c9;
}
/* Orbital Nodes (Top Arc) */
.orbital-arc {
position: relative;
display: flex;
align-items: center;
justify-content: center;
gap: 1.25rem;
margin-bottom: 2rem;
z-index: 20;
}
.arc-node {
width: 46px;
height: 46px;
border-radius: 50%;
border: 1px solid rgba(255, 255, 255, 0.3);
display: flex;
align-items: center;
justify-content: center;
color: #ffffff;
cursor: pointer;
background: rgba(0, 0, 0, 0.2);
transition: all 0.3s ease;
position: relative;
}
.arc-node i {
font-size: 20px;
}
.arc-node:hover {
border-color: rgba(255, 255, 255, 0.6);
background: rgba(255, 255, 255, 0.1);
}
.arc-node.active {
background: linear-gradient(135deg, #4f46e5, #ec4899);
border-color: transparent;
box-shadow: 0 0 20px rgba(236, 72, 153, 0.5);
transform: scale(1.15) translateY(-5px);
}
/* The little dot below the active node */
.arc-node.active::after {
content: '';
position: absolute;
bottom: -12px;
left: 50%;
transform: translateX(-50%);
width: 4px;
height: 4px;
background-color: #fff;
border-radius: 50%;
box-shadow: 0 0 5px #fff;
}
/* Top Header Notch Indicator */
.notch-indicator {
position: absolute;
top: -12px;
left: 50%;
transform: translateX(-50%);
width: 0;
height: 0;
border-left: 6px solid transparent;
border-right: 6px solid transparent;
border-bottom: 6px solid rgba(255, 255, 255, 0.2);
}
/* Bottom Dock */
.bottom-dock {
display: flex;
align-items: center;
justify-content: center;
gap: 1.5rem;
z-index: 20;
margin-bottom: 2rem;
}
.dock-btn {
width: 50px;
height: 50px;
border-radius: 50%;
border: 1px solid rgba(255, 255, 255, 0.3);
display: flex;
align-items: center;
justify-content: center;
color: #ffffff;
cursor: pointer;
background: transparent;
transition: all 0.3s ease;
font-size: 18px;
position: relative;
}
.dock-btn:hover {
border-color: rgba(255, 255, 255, 0.8);
}
.dock-btn.active {
background: linear-gradient(135deg, #3b28cc, #d414c9);
border-color: transparent;
box-shadow: 0 0 20px rgba(212, 20, 201, 0.4);
}
.dock-btn.active::after {
content: '';
position: absolute;
bottom: -15px;
left: 50%;
transform: translateX(-50%);
width: 5px;
height: 5px;
background-color: #fff;
border-radius: 50%;
box-shadow: 0 0 8px #fff;
}
</style>
</head>
<body class="h-screen w-screen flex flex-col items-center justify-between select-none">
<!-- Background Layers -->
<div class="stars-bg"></div>
<canvas id="visualizer-canvas"></canvas>
<!-- Top Header Title -->
<header class="w-full text-center pt-8 z-20 flex flex-col items-center">
<h1 class="text-xl font-semibold tracking-wide text-gray-200">Wave Surfer Pro</h1>
<p id="stream-status" class="text-xs text-gray-400 mt-1 uppercase tracking-widest">Radio 93.3 FM Live</p>
</header>
<!-- Main UI Container -->
<main class="flex-1 flex flex-col items-center justify-center w-full z-20">
<!-- Orbital Arc (Mode Selectors) -->
<div class="orbital-arc">
<!-- Music (Particles) -->
<div class="arc-node" data-mode="particles" title="Particles">
<i class="ph ph-music-notes"></i>
</div>
<!-- Sunrise (Cline) -->
<div class="arc-node" data-mode="cline" title="Cline Symmetrical">
<i class="ph ph-sun-horizon"></i>
</div>
<!-- Bulb (Bars - Default) -->
<div class="arc-node active" data-mode="bars" title="Radial Bars">
<i class="ph ph-lightbulb"></i>
</div>
<!-- Moon (Pulse) -->
<div class="arc-node" data-mode="pulse" title="Pulse Core">
<i class="ph ph-moon-stars"></i>
</div>
<!-- Candle (Fire/Nebula) -->
<div class="arc-node" data-mode="nebula" title="Nebula Flow">
<i class="ph ph-campfire"></i>
</div>
</div>
<!-- Central Power Orb -->
<div class="power-orb-wrapper" id="orb-wrapper">
<div class="notch-indicator"></div>
<div class="power-orb" id="btn-power">
<i class="ph ph-power power-icon"></i>
</div>
</div>
</main>
<!-- Bottom Dock -->
<footer class="w-full flex justify-center pb-12 z-20">
<div class="bottom-dock">
<!-- Primary Preset -->
<div class="dock-btn active" title="Theme: Cosmic">
<i class="ph ph-squares-four"></i>
</div>
<!-- Numbered Presets (Placeholders for UI Match) -->
<div class="dock-btn font-light" title="Theme: Neon">1</div>
<div class="dock-btn font-light" title="Theme: Amber">2</div>
<div class="dock-btn font-light" title="Theme: Mono">3</div>
</div>
</footer>
<!-- Native Audio Element (Hidden) -->
<audio id="audio-engine" crossorigin="anonymous" preload="auto" class="hidden"></audio>
<script>
// System Config
const RADIO_STREAM_URL = "https://5c7b683162943.streamlock.net/live/ngrp:radio-93-3_all/chunklist_w984020168_b981072.m3u8";
// Audio & Engine State
let audioCtx, analyser, sourceNode, gainNode, hls;
let isPlaying = false;
let currentMode = 'bars'; // Enforced default mode as requested
let isEngineInitialized = false;
// Data Buffers for Smoothing Physics
let bufferLength;
let dataArray;
let smoothedArray; // Temporal easing
let peaksArray; // Physics-based peak caps
let velocities; // Gravity acceleration for caps
// Canvas Setup
const canvas = document.getElementById('visualizer-canvas');
const ctx = canvas.getContext('2d');
const orbWrapper = document.getElementById('orb-wrapper');
function resizeCanvas() {
canvas.width = window.innerWidth;
canvas.height = window.innerHeight;
}
window.addEventListener('resize', resizeCanvas);
resizeCanvas();
// UI Elements
const audioElement = document.getElementById('audio-engine');
const btnPower = document.getElementById('btn-power');
const statusText = document.getElementById('stream-status');
const modeNodes = document.querySelectorAll('.arc-node');
function initAudioEngine() {
if (isEngineInitialized) return;
const AudioContext = window.AudioContext || window.webkitAudioContext;
audioCtx = new AudioContext();
analyser = audioCtx.createAnalyser();
// 512 for a good balance of bar thickness and detail
analyser.fftSize = 512;
// Native smoothing set low because we use custom physics math
analyser.smoothingTimeConstant = 0.4;
bufferLength = analyser.frequencyBinCount;
dataArray = new Uint8Array(bufferLength);
// Initialize physics arrays
smoothedArray = new Float32Array(bufferLength);
peaksArray = new Float32Array(bufferLength);
velocities = new Float32Array(bufferLength);
gainNode = audioCtx.createGain();
gainNode.gain.value = 1.0;
// Setup HLS Stream
if (Hls.isSupported()) {
hls = new Hls({ lowLatencyMode: true });
hls.loadSource(RADIO_STREAM_URL);
hls.attachMedia(audioElement);
hls.on(Hls.Events.MANIFEST_PARSED, () => {
connectMediaElement();
});
} else if (audioElement.canPlayType('application/vnd.apple.mpegurl')) {
audioElement.src = RADIO_STREAM_URL;
audioElement.addEventListener('loadedmetadata', () => {
connectMediaElement();
});
}
isEngineInitialized = true;
}
function connectMediaElement() {
if (!sourceNode && audioCtx) {
sourceNode = audioCtx.createMediaElementSource(audioElement);
sourceNode.connect(gainNode);
gainNode.connect(analyser);
analyser.connect(audioCtx.destination);
}
}
async function togglePlayback() {
if (!isEngineInitialized) {
initAudioEngine();
}
if (audioCtx.state === 'suspended') {
await audioCtx.resume();
}
if (isPlaying) {
audioElement.pause();
setPlayingState(false);
} else {
try {
await audioElement.play();
setPlayingState(true);
} catch (e) {
console.error("Playback failed:", e);
statusText.textContent = "Playback Blocked. Tap again.";
}
}
}
function setPlayingState(state) {
isPlaying = state;
if (state) {
btnPower.classList.add('is-playing');
statusText.textContent = "Radio 93.3 FM Live - Playing";
statusText.classList.add('text-pink-400');
} else {
btnPower.classList.remove('is-playing');
statusText.textContent = "Radio 93.3 FM Live - Paused";
statusText.classList.remove('text-pink-400');
}
}
function renderLoop() {
requestAnimationFrame(renderLoop);
// Clear canvas entirely each frame for crisp rendering
ctx.clearRect(0, 0, canvas.width, canvas.height);
if (!analyser || !isPlaying) return;
// Get live audio data
analyser.getByteFrequencyData(dataArray);
// Calculate center of the screen based on the Orb's exact position
const rect = orbWrapper.getBoundingClientRect();
const cx = rect.left + rect.width / 2;
const cy = rect.top + rect.height / 2;
const baseRadius = (rect.width / 2) + 15; // Start drawing just outside the orb ring
ctx.lineCap = 'round';
if (currentMode === 'bars') drawRadialBars(cx, cy, baseRadius);
else if (currentMode === 'cline') drawSymmetricalCline(cx, cy);
else if (currentMode === 'particles') drawParticles(cx, cy, baseRadius);
else if (currentMode === 'pulse') drawPulseCore(cx, cy, baseRadius);
else if (currentMode === 'nebula') drawRadialBars(cx, cy, baseRadius, true); // Variant
}
// SMOOTHED RADIAL BARS MODE (DEFAULT)
function drawRadialBars(cx, cy, baseRadius, isNebula = false) {
const barCount = 90; // Number of radial bars
const step = Math.floor(bufferLength / barCount);
for (let i = 0; i < barCount; i++) {
// Focus on lower and mid frequencies, skipping extreme highs
const dataIndex = Math.floor(i * step * 0.8);
const targetValue = dataArray[dataIndex] / 255;
// 1. Math Interpolation for Buttery Smoothness (Easing)
smoothedArray[i] += (targetValue - smoothedArray[i]) * 0.2;
const value = smoothedArray[i];
// 2. Physics-based peak gravity caps
if (value >= peaksArray[i]) {
peaksArray[i] = value;
velocities[i] = 0; // Reset velocity on hit
} else {
velocities[i] += 0.002; // Gravity acceleration
peaksArray[i] -= velocities[i]; // Apply falling velocity
if (peaksArray[i] < 0) peaksArray[i] = 0;
}
// Calculate angle (Start from top, go clockwise)
const angle = (i / barCount) * Math.PI * 2 - (Math.PI / 2);
// Calculate bar length based on screen size
const maxBarLength = Math.min(canvas.width, canvas.height) * 0.3;
const barLength = value * maxBarLength;
const x1 = cx + Math.cos(angle) * baseRadius;
const y1 = cy + Math.sin(angle) * baseRadius;
const x2 = cx + Math.cos(angle) * (baseRadius + barLength);
const y2 = cy + Math.sin(angle) * (baseRadius + barLength);
// Gradient for the bars
const gradient = ctx.createLinearGradient(x1, y1, x2, y2);
gradient.addColorStop(0, '#3b28cc'); // Deep Blue
gradient.addColorStop(0.5, '#9333ea'); // Purple
gradient.addColorStop(1, '#d414c9'); // Pink/Magenta
// Draw Main Bar
ctx.beginPath();
ctx.moveTo(x1, y1);
ctx.lineTo(x2, y2);
ctx.lineWidth = 4;
ctx.strokeStyle = isNebula ? 'rgba(212, 20, 201, 0.4)' : gradient;
ctx.stroke();
if (!isNebula) {
// Draw Physics Cap (Falling Dot) at the peak
const capRadius = baseRadius + (peaksArray[i] * maxBarLength) + 6;
const cx2 = cx + Math.cos(angle) * capRadius;
const cy2 = cy + Math.sin(angle) * capRadius;
ctx.beginPath();
ctx.arc(cx2, cy2, 2.5, 0, Math.PI * 2);
ctx.fillStyle = '#ffffff';
ctx.shadowBlur = 8;
ctx.shadowColor = '#d414c9';
ctx.fill();
ctx.shadowBlur = 0; // Reset
}
}
}
function drawSymmetricalCline(cx, cy) {
ctx.lineWidth = 3;
ctx.shadowBlur = 15;
ctx.shadowColor = '#d414c9';
const renderWidth = canvas.width * 0.8;
const startX = (canvas.width - renderWidth) / 2;
const sliceWidth = renderWidth / bufferLength;
const grad = ctx.createLinearGradient(startX, 0, startX + renderWidth, 0);
grad.addColorStop(0, '#3b28cc');
grad.addColorStop(0.5, '#d414c9');
grad.addColorStop(1, '#3b28cc');
ctx.strokeStyle = grad;
// Top mirrored half
ctx.beginPath();
let x = startX;
for(let i = 0; i < bufferLength; i++) {
smoothedArray[i] += ((dataArray[i]/255) - smoothedArray[i]) * 0.2;
const v = smoothedArray[i] * 150; // Amplitude
if(i === 0) ctx.moveTo(x, cy - v); else ctx.lineTo(x, cy - v);
x += sliceWidth;
}
ctx.stroke();
// Bottom mirrored half
ctx.beginPath();
x = startX;
for(let i = 0; i < bufferLength; i++) {
const v = smoothedArray[i] * 150;
if(i === 0) ctx.moveTo(x, cy + v); else ctx.lineTo(x, cy + v);
x += sliceWidth;
}
ctx.stroke();
ctx.shadowBlur = 0;
}
function drawParticles(cx, cy, baseRadius) {
const count = 80;
const step = Math.floor(bufferLength / count);
for(let i=0; i<count; i++) {
smoothedArray[i] += ((dataArray[i*step]/255) - smoothedArray[i]) * 0.1;
const val = smoothedArray[i];
if(val < 0.05) continue;
const angle = (i/count) * Math.PI * 2 + (Date.now()*0.0002);
const distance = baseRadius + (val * 250);
const px = cx + Math.cos(angle) * distance;
const py = cy + Math.sin(angle) * distance;
ctx.beginPath();
ctx.arc(px, py, val * 6 + 1, 0, Math.PI*2);
ctx.fillStyle = i % 2 === 0 ? '#d414c9' : '#3b28cc';
ctx.shadowBlur = 10;
ctx.shadowColor = ctx.fillStyle;
ctx.fill();
}
ctx.shadowBlur = 0;
}
function drawPulseCore(cx, cy, baseRadius) {
let sum = 0;
for(let i=0; i<20; i++) sum += dataArray[i];
const avgBass = (sum / 20) / 255;
const radius = baseRadius + (avgBass * 100);
ctx.beginPath();
ctx.arc(cx, cy, radius, 0, Math.PI*2);
ctx.lineWidth = 2 + (avgBass * 15);
ctx.strokeStyle = `rgba(212, 20, 201, ${0.4 + avgBass})`;
ctx.shadowBlur = 40 * avgBass;
ctx.shadowColor = '#d414c9';
ctx.stroke();
ctx.shadowBlur = 0;
}
// Event Listeners
btnPower.addEventListener('click', togglePlayback);
modeNodes.forEach(btn => {
btn.addEventListener('click', (e) => {
modeNodes.forEach(b => b.classList.remove('active'));
const target = e.currentTarget;
target.classList.add('active');
currentMode = target.dataset.mode;
});
});
// Start animation loop
requestAnimationFrame(renderLoop);
</script>
</body>
</html>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment