Spaces:
Running
Running
<html lang="en"> | |
<head> | |
<meta charset="UTF-8"> | |
<meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
<title>3D Audio Spectrum Analyzer</title> | |
<style> | |
:root { | |
--primary-color: #1a1a2e; | |
--secondary-color: #16213e; | |
--accent-color: #0f3460; | |
--text-color: #e7e7e7; | |
--highlight-color: #4cc9f0; | |
--gradient-1: #4361ee; | |
--gradient-2: #3a0ca3; | |
--gradient-3: #7209b7; | |
--gradient-4: #f72585; | |
} | |
* { | |
margin: 0; | |
padding: 0; | |
box-sizing: border-box; | |
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; | |
} | |
body { | |
background-color: var(--primary-color); | |
color: var(--text-color); | |
overflow: hidden; | |
display: flex; | |
flex-direction: column; | |
min-height: 100vh; | |
} | |
header { | |
background-color: var(--secondary-color); | |
padding: 1rem; | |
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.3); | |
z-index: 10; | |
} | |
.title { | |
text-align: center; | |
font-size: 1.8rem; | |
font-weight: 600; | |
color: var(--highlight-color); | |
letter-spacing: 1px; | |
text-transform: uppercase; | |
} | |
.canvas-container { | |
flex: 1; | |
position: relative; | |
} | |
#visualizer { | |
position: absolute; | |
top: 0; | |
left: 0; | |
width: 100%; | |
height: 100%; | |
} | |
.control-panel { | |
position: absolute; | |
top: 1rem; | |
right: 1rem; | |
background-color: rgba(22, 33, 62, 0.8); | |
backdrop-filter: blur(10px); | |
border-radius: 12px; | |
padding: 1.2rem; | |
width: 280px; | |
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3); | |
z-index: 100; | |
transition: transform 0.3s ease; | |
} | |
.control-panel.collapsed { | |
transform: translateX(calc(100% - 50px)); | |
} | |
.toggle-panel { | |
position: absolute; | |
left: 10px; | |
top: 50%; | |
transform: translateY(-50%); | |
background-color: var(--accent-color); | |
border: none; | |
color: var(--text-color); | |
width: 30px; | |
height: 30px; | |
border-radius: 50%; | |
cursor: pointer; | |
display: flex; | |
align-items: center; | |
justify-content: center; | |
font-size: 1.2rem; | |
transition: background-color 0.2s ease; | |
} | |
.toggle-panel:hover { | |
background-color: var(--highlight-color); | |
} | |
h2 { | |
margin-bottom: 1rem; | |
font-size: 1.3rem; | |
color: var(--highlight-color); | |
text-align: center; | |
} | |
.control-group { | |
margin-bottom: 1.2rem; | |
} | |
.control-label { | |
display: block; | |
margin-bottom: 0.5rem; | |
font-weight: 500; | |
font-size: 0.9rem; | |
color: var(--text-color); | |
} | |
.btn { | |
background-color: var(--accent-color); | |
color: var(--text-color); | |
border: none; | |
border-radius: 8px; | |
padding: 0.7rem 1rem; | |
font-size: 1rem; | |
cursor: pointer; | |
transition: all 0.2s ease; | |
display: inline-flex; | |
align-items: center; | |
justify-content: center; | |
gap: 0.5rem; | |
width: 100%; | |
} | |
.btn:hover { | |
background-color: var(--highlight-color); | |
color: var(--primary-color); | |
} | |
.btn:disabled { | |
opacity: 0.6; | |
cursor: not-allowed; | |
} | |
.btn-record { | |
background-color: #e63946; | |
} | |
.btn-record:hover { | |
background-color: #ff6b6b; | |
} | |
.btn-record.recording { | |
animation: pulse 1.5s infinite; | |
} | |
@keyframes pulse { | |
0% { | |
background-color: #e63946; | |
} | |
50% { | |
background-color: #ff6b6b; | |
} | |
100% { | |
background-color: #e63946; | |
} | |
} | |
.range-slider { | |
width: 100%; | |
margin: 0.5rem 0; | |
} | |
.slider-container { | |
display: flex; | |
align-items: center; | |
gap: 1rem; | |
} | |
.slider-value { | |
width: 50px; | |
text-align: center; | |
font-size: 0.9rem; | |
color: var(--highlight-color); | |
} | |
.radio-group { | |
display: flex; | |
gap: 0.8rem; | |
margin-top: 0.5rem; | |
} | |
.radio-option { | |
display: flex; | |
align-items: center; | |
gap: 0.3rem; | |
cursor: pointer; | |
} | |
.radio-option input { | |
cursor: pointer; | |
} | |
.status-indicator { | |
display: flex; | |
align-items: center; | |
gap: 0.5rem; | |
margin-top: 1rem; | |
padding: 0.5rem; | |
border-radius: 8px; | |
background-color: rgba(15, 52, 96, 0.5); | |
} | |
.status-dot { | |
width: 10px; | |
height: 10px; | |
border-radius: 50%; | |
background-color: #6c757d; | |
} | |
.status-dot.active { | |
background-color: #4cc9f0; | |
box-shadow: 0 0 8px #4cc9f0; | |
animation: glow 1.5s infinite alternate; | |
} | |
@keyframes glow { | |
from { | |
box-shadow: 0 0 5px #4cc9f0; | |
} | |
to { | |
box-shadow: 0 0 12px #4cc9f0; | |
} | |
} | |
.status-text { | |
font-size: 0.9rem; | |
} | |
@media (max-width: 768px) { | |
.control-panel { | |
width: 250px; | |
} | |
.title { | |
font-size: 1.5rem; | |
} | |
} | |
</style> | |
</head> | |
<body> | |
<header> | |
<h1 class="title">3D Audio Spectrum Analyzer</h1> | |
</header> | |
<div class="canvas-container"> | |
<canvas id="visualizer"></canvas> | |
</div> | |
<div class="control-panel"> | |
<button class="toggle-panel">≡</button> | |
<h2>Control Panel</h2> | |
<div class="control-group"> | |
<button id="startBtn" class="btn btn-record"> | |
<span class="btn-icon">◉</span> Start Microphone | |
</button> | |
</div> | |
<div class="control-group"> | |
<label class="control-label">Color Scheme</label> | |
<div class="radio-group"> | |
<label class="radio-option"> | |
<input type="radio" name="colorScheme" value="blue" checked> | |
<span>Blue</span> | |
</label> | |
<label class="radio-option"> | |
<input type="radio" name="colorScheme" value="red"> | |
<span>Red</span> | |
</label> | |
<label class="radio-option"> | |
<input type="radio" name="colorScheme" value="rainbow"> | |
<span>Rainbow</span> | |
</label> | |
</div> | |
</div> | |
<div class="control-group"> | |
<label class="control-label">Frequency Range</label> | |
<div class="slider-container"> | |
<input type="range" class="range-slider" id="frequencyRange" min="0" max="100" value="100"> | |
<span class="slider-value" id="frequencyValue">100%</span> | |
</div> | |
</div> | |
<div class="control-group"> | |
<label class="control-label">Sensitivity</label> | |
<div class="slider-container"> | |
<input type="range" class="range-slider" id="sensitivityRange" min="1" max="10" value="5"> | |
<span class="slider-value" id="sensitivityValue">5</span> | |
</div> | |
</div> | |
<div class="control-group"> | |
<label class="control-label">3D Effect Depth</label> | |
<div class="slider-container"> | |
<input type="range" class="range-slider" id="depthRange" min="1" max="10" value="5"> | |
<span class="slider-value" id="depthValue">5</span> | |
</div> | |
</div> | |
<div class="status-indicator"> | |
<div class="status-dot" id="statusDot"></div> | |
<div class="status-text" id="statusText">Waiting for microphone...</div> | |
</div> | |
</div> | |
<script src="https://cdn.jsdelivr.net/npm/three@0.132.2/build/three.min.js"></script> | |
<script> | |
class AudioVisualizer { | |
constructor() { | |
// DOM elements | |
this.canvas = document.getElementById('visualizer'); | |
this.startBtn = document.getElementById('startBtn'); | |
this.statusDot = document.getElementById('statusDot'); | |
this.statusText = document.getElementById('statusText'); | |
this.frequencyRange = document.getElementById('frequencyRange'); | |
this.frequencyValue = document.getElementById('frequencyValue'); | |
this.sensitivityRange = document.getElementById('sensitivityRange'); | |
this.sensitivityValue = document.getElementById('sensitivityValue'); | |
this.depthRange = document.getElementById('depthRange'); | |
this.depthValue = document.getElementById('depthValue'); | |
this.togglePanel = document.querySelector('.toggle-panel'); | |
this.controlPanel = document.querySelector('.control-panel'); | |
this.colorOptions = document.querySelectorAll('input[name="colorScheme"]'); | |
// Audio context and analyzer | |
this.audioContext = null; | |
this.analyser = null; | |
this.dataArray = null; | |
this.source = null; | |
this.isRecording = false; | |
// Three.js variables | |
this.scene = null; | |
this.camera = null; | |
this.renderer = null; | |
this.terrain = null; | |
this.colorScheme = 'blue'; | |
this.maxFrequencyPercent = 100; | |
this.sensitivity = 5; | |
this.depth = 5; | |
// Grid size for the 3D visualization | |
this.gridSize = 64; | |
this.vertices = []; | |
this.heights = []; | |
this.init(); | |
} | |
init() { | |
// Initialize Three.js | |
this.initThree(); | |
// Initialize UI controls | |
this.initControls(); | |
// Start render loop | |
this.animate(); | |
// Handle window resize | |
window.addEventListener('resize', () => this.onWindowResize()); | |
} | |
initThree() { | |
// Create scene | |
this.scene = new THREE.Scene(); | |
// Create camera | |
this.camera = new THREE.PerspectiveCamera(60, window.innerWidth / window.innerHeight, 0.1, 1000); | |
this.camera.position.set(0, 25, 50); | |
this.camera.lookAt(0, 0, 0); | |
// Create renderer | |
this.renderer = new THREE.WebGLRenderer({ canvas: this.canvas, antialias: true }); | |
this.renderer.setSize(window.innerWidth, window.innerHeight); | |
this.renderer.setClearColor(0x1a1a2e); | |
// Add ambient light | |
const ambientLight = new THREE.AmbientLight(0xffffff, 0.4); | |
this.scene.add(ambientLight); | |
// Add directional light | |
const directionalLight = new THREE.DirectionalLight(0xffffff, 0.8); | |
directionalLight.position.set(1, 1, 1); | |
this.scene.add(directionalLight); | |
// Create the terrain mesh | |
this.createTerrain(); | |
} | |
createTerrain() { | |
// Create grid geometry | |
const geometry = new THREE.PlaneGeometry(60, 60, this.gridSize - 1, this.gridSize - 1); | |
geometry.rotateX(-Math.PI / 2); | |
// Store original vertices | |
this.vertices = geometry.attributes.position.array; | |
this.heights = new Array(this.vertices.length / 3).fill(0); | |
// Create material | |
const material = new THREE.MeshStandardMaterial({ | |
color: 0x4cc9f0, | |
wireframe: false, | |
flatShading: true, | |
metalness: 0.3, | |
roughness: 0.7, | |
vertexColors: true | |
}); | |
// Create vertex colors | |
const count = geometry.attributes.position.count; | |
const colors = new Float32Array(count * 3); | |
for (let i = 0; i < count; i++) { | |
const x = geometry.attributes.position.array[i * 3]; | |
const z = geometry.attributes.position.array[i * 3 + 2]; | |
const distance = Math.sqrt(x * x + z * z); | |
const normalizedDistance = Math.min(1, distance / 30); | |
// Default blue color scheme | |
colors[i * 3] = 0.2 + normalizedDistance * 0.2; // R | |
colors[i * 3 + 1] = 0.5 + normalizedDistance * 0.3; // G | |
colors[i * 3 + 2] = 0.8; // B | |
} | |
geometry.setAttribute('color', new THREE.BufferAttribute(colors, 3)); | |
// Create and add mesh | |
this.terrain = new THREE.Mesh(geometry, material); | |
this.terrain.position.y = -10; | |
this.scene.add(this.terrain); | |
} | |
updateTerrainColors() { | |
const colors = this.terrain.geometry.attributes.color.array; | |
const count = this.terrain.geometry.attributes.position.count; | |
for (let i = 0; i < count; i++) { | |
const x = this.terrain.geometry.attributes.position.array[i * 3]; | |
const z = this.terrain.geometry.attributes.position.array[i * 3 + 2]; | |
const distance = Math.sqrt(x * x + z * z); | |
const normalizedDistance = Math.min(1, distance / 30); | |
const height = this.heights[i] / 10; // Normalized height | |
// Apply different color schemes | |
if (this.colorScheme === 'blue') { | |
colors[i * 3] = 0.2 + normalizedDistance * 0.2; // R | |
colors[i * 3 + 1] = 0.5 + normalizedDistance * 0.3; // G | |
colors[i * 3 + 2] = 0.8 - height * 0.3; // B | |
} else if (this.colorScheme === 'red') { | |
colors[i * 3] = 0.8 - height * 0.3; // R | |
colors[i * 3 + 1] = 0.2 + normalizedDistance * 0.2; // G | |
colors[i * 3 + 2] = 0.3 + normalizedDistance * 0.3; // B | |
} else if (this.colorScheme === 'rainbow') { | |
// Rainbow color scheme | |
const hue = (normalizedDistance + height) * 360; | |
const saturation = 0.8; | |
const lightness = 0.5 + height * 0.3; | |
// Convert HSL to RGB | |
const c = (1 - Math.abs(2 * lightness - 1)) * saturation; | |
const x = c * (1 - Math.abs((hue / 60) % 2 - 1)); | |
const m = lightness - c / 2; | |
let r, g, b; | |
if (hue < 60) { | |
[r, g, b] = [c, x, 0]; | |
} else if (hue < 120) { | |
[r, g, b] = [x, c, 0]; | |
} else if (hue < 180) { | |
[r, g, b] = [0, c, x]; | |
} else if (hue < 240) { | |
[r, g, b] = [0, x, c]; | |
} else if (hue < 300) { | |
[r, g, b] = [x, 0, c]; | |
} else { | |
[r, g, b] = [c, 0, x]; | |
} | |
colors[i * 3] = r + m; | |
colors[i * 3 + 1] = g + m; | |
colors[i * 3 + 2] = b + m; | |
} | |
} | |
this.terrain.geometry.attributes.color.needsUpdate = true; | |
} | |
animate() { | |
requestAnimationFrame(() => this.animate()); | |
// Update terrain based on audio data | |
if (this.isRecording && this.dataArray) { | |
this.updateTerrainGeometry(); | |
} else { | |
// Idle animation when not recording | |
this.idleAnimation(); | |
} | |
// Render scene | |
this.renderer.render(this.scene, this.camera); | |
} | |
updateTerrainGeometry() { | |
// Get frequency data | |
this.analyser.getByteFrequencyData(this.dataArray); | |
// Calculate the number of frequency bins to use based on the frequency range slider | |
const maxBinIndex = Math.floor(this.dataArray.length * (this.maxFrequencyPercent / 100)); | |
// Update vertices based on frequency data | |
for (let i = 0; i < this.gridSize; i++) { | |
for (let j = 0; j < this.gridSize; j++) { | |
const index = i * this.gridSize + j; | |
const vertexIndex = index * 3 + 1; // Y component | |
// Map grid position to frequency bin | |
const binIndex = Math.floor((i * this.gridSize + j) * maxBinIndex / (this.gridSize * this.gridSize)); | |
// Get amplitude from frequency data | |
const amplitude = this.dataArray[binIndex] / 255.0; | |
// Apply sensitivity multiplier | |
const heightValue = amplitude * (this.sensitivity * 2); | |
// Apply depth effect | |
const distanceFromCenter = Math.sqrt( | |
Math.pow((i - this.gridSize / 2) / (this.gridSize / 2), 2) + | |
Math.pow((j - this.gridSize / 2) / (this.gridSize / 2), 2) | |
); | |
// Apply distance falloff based on depth setting | |
const falloff = Math.max(0, 1 - distanceFromCenter * (1 - this.depth / 10)); | |
// Calculate new height | |
this.heights[index] = heightValue * 10 * falloff; | |
// Update vertex position | |
this.vertices[vertexIndex] = this.heights[index]; | |
} | |
} | |
// Update colors | |
this.updateTerrainColors(); | |
// Update geometry | |
this.terrain.geometry.attributes.position.needsUpdate = true; | |
} | |
idleAnimation() { | |
// Simple idle wave animation | |
const time = Date.now() * 0.001; | |
for (let i = 0; i < this.gridSize; i++) { | |
for (let j = 0; j < this.gridSize; j++) { | |
const index = i * this.gridSize + j; | |
const vertexIndex = index * 3 + 1; // Y component | |
const x = (i - this.gridSize / 2) / 5; | |
const z = (j - this.gridSize / 2) / 5; | |
// Generate a gentle wave pattern | |
const height = Math.sin(x + time) * Math.cos(z + time) * 0.5; | |
// Store height for color calculations | |
this.heights[index] = height * 3; | |
// Update vertex position | |
this.vertices[vertexIndex] = height * 3; | |
} | |
} | |
// Update colors | |
this.updateTerrainColors(); | |
// Update geometry | |
this.terrain.geometry.attributes.position.needsUpdate = true; | |
} | |
onWindowResize() { | |
// Update camera aspect ratio | |
this.camera.aspect = window.innerWidth / window.innerHeight; | |
this.camera.updateProjectionMatrix(); | |
// Update renderer size | |
this.renderer.setSize(window.innerWidth, window.innerHeight); | |
} | |
async startMicrophone() { | |
try { | |
// Request microphone access | |
const stream = await navigator.mediaDevices.getUserMedia({ audio: true }); | |
// Create audio context | |
this.audioContext = new (window.AudioContext || window.webkitAudioContext)(); | |
// Create analyzer | |
this.analyser = this.audioContext.createAnalyser(); | |
this.analyser.fftSize = 2048; | |
this.analyser.smoothingTimeConstant = 0.85; | |
// Create buffer for frequency data | |
this.dataArray = new Uint8Array(this.analyser.frequencyBinCount); | |
// Connect microphone to analyzer | |
this.source = this.audioContext.createMediaStreamSource(stream); | |
this.source.connect(this.analyser); | |
// Update UI | |
this.isRecording = true; | |
this.updateUIState(); | |
} catch (error) { | |
console.error('Error accessing microphone:', error); | |
this.statusText.textContent = 'Microphone access denied'; | |
} | |
} | |
stopMicrophone() { | |
if (this.source) { | |
this.source.disconnect(); | |
this.source = null; | |
} | |
if (this.audioContext) { | |
this.audioContext.close().then(() => { | |
this.audioContext = null; | |
this.analyser = null; | |
this.dataArray = null; | |
this.isRecording = false; | |
this.updateUIState(); | |
}); | |
} else { | |
this.isRecording = false; | |
this.updateUIState(); | |
} | |
} | |
updateUIState() { | |
if (this.isRecording) { | |
this.startBtn.innerHTML = '<span class="btn-icon">■</span> Stop Microphone'; | |
this.startBtn.classList.add('recording'); | |
this.statusDot.classList.add('active'); | |
this.statusText.textContent = 'Microphone active'; | |
} else { | |
this.startBtn.innerHTML = '<span class="btn-icon">◉</span> Start Microphone'; | |
this.startBtn.classList.remove('recording'); | |
this.statusDot.classList.remove('active'); | |
this.statusText.textContent = 'Microphone inactive'; | |
} | |
} | |
initControls() { | |
// Start/stop button | |
this.startBtn.addEventListener('click', () => { | |
if (this.isRecording) { | |
this.stopMicrophone(); | |
} else { | |
this.startMicrophone(); | |
} | |
}); | |
// Frequency range slider | |
this.frequencyRange.addEventListener('input', (e) => { | |
this.maxFrequencyPercent = parseInt(e.target.value); | |
this.frequencyValue.textContent = `${this.maxFrequencyPercent}%`; | |
}); | |
// Sensitivity slider | |
this.sensitivityRange.addEventListener('input', (e) => { | |
this.sensitivity = parseInt(e.target.value); | |
this.sensitivityValue.textContent = this.sensitivity; | |
}); | |
// Depth slider | |
this.depthRange.addEventListener('input', (e) => { | |
this.depth = parseInt(e.target.value); | |
this.depthValue.textContent = this.depth; | |
}); | |
// Color scheme radio buttons | |
this.colorOptions.forEach(option => { | |
option.addEventListener('change', (e) => { | |
this.colorScheme = e.target.value; | |
this.updateTerrainColors(); | |
}); | |
}); | |
// Toggle panel button | |
this.togglePanel.addEventListener('click', () => { | |
this.controlPanel.classList.toggle('collapsed'); | |
this.togglePanel.textContent = this.controlPanel.classList.contains('collapsed') ? '≫' : '≡'; | |
}); | |
} | |
} | |
// Initialize the visualizer when the page loads | |
window.addEventListener('DOMContentLoaded', () => { | |
new AudioVisualizer(); | |
}); | |
</script> | |
</body> | |
</html> |