This commit is contained in:
Rob Colbert 2026-05-09 22:31:56 -04:00
parent 09e2510885
commit 769221948d
17 changed files with 4733 additions and 0 deletions

2
.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
dist
node_modules

66
index.html Normal file
View File

@ -0,0 +1,66 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Gadget Radio - Cyberpunk Mainframe HUD</title>
</head>
<body class="bg-black overflow-hidden font-mono">
<!-- Three.js Canvas Container -->
<div id="canvas-container" class="fixed inset-0 z-10"></div>
<!-- Scanlines Overlay -->
<div id="terminal-overlay" class="scanlines fixed inset-0 pointer-events-none z-20"></div>
<!-- RGB Shift Overlay -->
<div id="rgb-shift-overlay" class="rgb-shift fixed inset-0 pointer-events-none z-30"></div>
<!-- Data Readouts - Top Left -->
<div class="data-panel fixed top-4 left-4 z-40" id="panel-top-left">
<div class="mb-2">/// GADGET RADIO /// v1.0.0 ///</div>
<div class="text-lime" id="status">>> SYSTEM: ONLINE</div>
<div class="mt-2">
<div>> BASS: <span id="freq-bass" class="text-cyan">0</span> Hz</div>
<div>> MIDS: <span id="freq-mids" class="text-cyan">0</span> Hz</div>
<div>> TREBLE: <span id="freq-treble" class="text-cyan">0</span> Hz</div>
</div>
<div class="mt-1">
<div>> VOLUME: <span id="volume" class="text-cyan">0</span> dB</div>
</div>
<div class="progress-bar">
<div class="progress-fill" id="volume-bar"></div>
</div>
</div>
<!-- Data Readouts - Top Right -->
<div class="data-panel fixed top-4 right-4 z-40 text-right" id="panel-top-right">
<div>> FFT_SIZE: <span id="fft-size" class="text-cyan"></span></div>
<div>> SAMPLE_RATE: <span id="sample-rate" class="text-cyan"></span></div>
<div>> CHANNELS: <span id="channels" class="text-cyan"></span></div>
</div>
<!-- Data Readouts - Bottom Left -->
<div class="data-panel fixed bottom-4 left-4 z-40" id="panel-bottom-left">
<div>> TRACK: <span id="track-name" class="text-lime">NO FILE LOADED</span></div>
<div class="mt-1">> LOOP: <span class="text-green">ENABLED</span></div>
<div class="mt-1">> STREAM: <span class="text-cyan">ACTIVE</span></div>
</div>
<!-- Data Readouts - Bottom Right -->
<div class="data-panel fixed bottom-4 right-4 z-40 text-right" id="panel-bottom-right">
<div>> TIME: <span id="current-time" class="text-cyan">00:00</span></div>
<div>> DURATION: <span id="duration" class="text-cyan">00:00</span></div>
<div class="mt-1">> FPS: <span id="fps-counter" class="text-cyan">60</span></div>
</div>
<!-- File Upload Overlay -->
<div id="upload-container" class="upload-container fixed inset-0 flex items-center justify-center z-50 pointer-events-all transition-opacity duration-500">
<button id="file-upload-btn" class="upload-button font-mono">
▶ LOAD AUDIO FILE
</button>
<input type="file" id="file-input" accept="audio/*" class="hidden" />
</div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

2179
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

23
package.json Normal file
View File

@ -0,0 +1,23 @@
{
"name": "gadget-radio",
"version": "1.0.0",
"description": "Cyberpunk music visualizer for live streaming radio",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview"
},
"dependencies": {
"three": "^0.154.0"
},
"devDependencies": {
"@types/three": "^0.154.0",
"autoprefixer": "^10.4.19",
"postcss": "^8.4.38",
"tailwindcss": "^3.4.4",
"typescript": "^5.4.5",
"vite": "^5.3.1"
},
"packageManager": "pnpm@11.0.9+sha512.34ce82e6780233cf9cad8685029a8f81d2e06196c5a9bad98879f7424940c6817c4e4524fb7d38b8553ceed48b9758b8ebaf1abd3600c232c4c8cf7366086f38"
}

1280
pnpm-lock.yaml Normal file

File diff suppressed because it is too large Load Diff

2
pnpm-workspace.yaml Normal file
View File

@ -0,0 +1,2 @@
allowBuilds:
esbuild: true

6
postcss.config.js Normal file
View File

@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

132
src/audio/AudioEngine.ts Normal file
View File

@ -0,0 +1,132 @@
import { AppConfig, DEFAULT_CONFIG } from '@/types/audio'
export class AudioEngine {
private audioContext: AudioContext | null = null
private analyser: AnalyserNode | null = null
private source: MediaElementAudioSourceNode | null = null
private audioElement: HTMLAudioElement | null = null
private config: AppConfig
constructor(config: AppConfig = DEFAULT_CONFIG) {
this.config = config
}
public async loadAudio(file: File): Promise<void> {
if (!this.audioContext) {
this.audioContext = new AudioContext()
}
if (this.audioContext.state === 'suspended') {
await this.audioContext.resume()
}
this.cleanup()
const url = URL.createObjectURL(file)
this.audioElement = new Audio(url)
this.audioElement.crossOrigin = 'anonymous'
this.audioElement.loop = true
this.audioElement.load()
await new Promise<void>((resolve, reject) => {
if (!this.audioElement) return reject(new Error('No audio element'))
this.audioElement.oncanplaythrough = () => resolve()
this.audioElement.onerror = () => reject(new Error('Failed to load audio'))
})
this.setupAnalyser()
}
private setupAnalyser(): void {
if (!this.audioContext || !this.audioElement) return
this.analyser = this.audioContext.createAnalyser()
this.analyser.fftSize = this.config.fftSize
this.analyser.smoothingTimeConstant = this.config.smoothingTimeConstant
this.analyser.minDecibels = this.config.minDecibels
this.analyser.maxDecibels = this.config.maxDecibels
this.source = this.audioContext.createMediaElementSource(this.audioElement)
this.source.connect(this.analyser)
this.analyser.connect(this.audioContext.destination)
}
public async play(): Promise<void> {
if (!this.audioContext || !this.audioElement) return
if (this.audioContext.state === 'suspended') {
await this.audioContext.resume()
}
await this.audioElement.play()
}
public pause(): void {
this.audioElement?.pause()
}
public togglePlay(): void {
if (this.audioElement?.paused) {
this.play()
} else {
this.pause()
}
}
public getAnalyser(): AnalyserNode | null {
return this.analyser
}
public getAudioElement(): HTMLAudioElement | null {
return this.audioElement
}
public getCurrentTime(): number {
return this.audioElement?.currentTime ?? 0
}
public getDuration(): number {
return this.audioElement?.duration ?? 0
}
public isPlaying(): boolean {
return this.audioElement?.paused === false
}
public setVolume(value: number): void {
if (this.audioElement) {
this.audioElement.volume = Math.max(0, Math.min(1, value))
}
}
public getVolume(): number {
return this.audioElement?.volume ?? 1
}
private cleanup(): void {
if (this.audioElement) {
this.audioElement.pause()
this.audioElement.src = ''
this.audioElement = null
}
if (this.source) {
this.source.disconnect()
this.source = null
}
if (this.analyser) {
this.analyser.disconnect()
this.analyser = null
}
}
public dispose(): void {
this.cleanup()
if (this.audioContext) {
this.audioContext.close()
this.audioContext = null
}
}
}

130
src/core/RadioPlayer.ts Normal file
View File

@ -0,0 +1,130 @@
import { AudioEngine } from '@/audio/AudioEngine'
import { AudioVisualizer } from '@/visualizer/AudioVisualizer'
import { AudioData, AppConfig, DEFAULT_CONFIG, RadioState } from '@/types/audio'
export class RadioPlayer {
private audioEngine: AudioEngine
private visualizer: AudioVisualizer
private onAudioDataUpdate?: (data: AudioData) => void
private onStateChange?: (state: RadioState) => void
private updateInterval: number | null = null
constructor(
container: HTMLElement,
config: AppConfig = DEFAULT_CONFIG,
callbacks?: {
onAudioDataUpdate?: (data: AudioData) => void
onStateChange?: (state: RadioState) => void
}
) {
this.audioEngine = new AudioEngine(config)
this.visualizer = new AudioVisualizer({
container,
onGlitch: () => {
callbacks?.onStateChange?.({
isPlaying: this.audioEngine.isPlaying(),
isLooping: true,
duration: this.audioEngine.getDuration(),
currentTime: this.audioEngine.getCurrentTime(),
isGlitching: true,
})
// Reset glitch state after animation
setTimeout(() => {
callbacks?.onStateChange?.({
isPlaying: this.audioEngine.isPlaying(),
isLooping: true,
duration: this.audioEngine.getDuration(),
currentTime: this.audioEngine.getCurrentTime(),
isGlitching: false,
})
}, 450)
},
})
this.onAudioDataUpdate = callbacks?.onAudioDataUpdate
this.onStateChange = callbacks?.onStateChange
}
public async loadTrack(file: File): Promise<void> {
await this.audioEngine.loadAudio(file)
const analyser = this.audioEngine.getAnalyser()
if (analyser) {
this.visualizer.connectAnalyser(analyser)
}
this.startDataUpdate()
}
private startDataUpdate(): void {
this.stopDataUpdate()
this.updateInterval = window.setInterval(() => {
const audioData = this.visualizer.getAudioData()
if (audioData) {
this.onAudioDataUpdate?.(audioData)
}
}, 100) // 10 FPS for UI updates
}
private stopDataUpdate(): void {
if (this.updateInterval !== null) {
clearInterval(this.updateInterval)
this.updateInterval = null
}
}
public async play(): Promise<void> {
await this.audioEngine.play()
this.onStateChange?.({
isPlaying: true,
isLooping: true,
duration: this.audioEngine.getDuration(),
currentTime: this.audioEngine.getCurrentTime(),
})
}
public pause(): void {
this.audioEngine.pause()
this.onStateChange?.({
isPlaying: false,
isLooping: true,
duration: this.audioEngine.getDuration(),
currentTime: this.audioEngine.getCurrentTime(),
})
}
public togglePlay(): void {
if (this.audioEngine.isPlaying()) {
this.pause()
} else {
this.play()
}
}
public setVolume(value: number): void {
this.audioEngine.setVolume(value)
}
public getVolume(): number {
return this.audioEngine.getVolume()
}
public getCurrentTime(): number {
return this.audioEngine.getCurrentTime()
}
public getDuration(): number {
return this.audioEngine.getDuration()
}
public isPlaying(): boolean {
return this.audioEngine.isPlaying()
}
public getAnalyser(): AnalyserNode | null {
return this.audioEngine.getAnalyser()
}
public dispose(): void {
this.stopDataUpdate()
this.visualizer.dispose()
this.audioEngine.dispose()
}
}

179
src/main.ts Normal file
View File

@ -0,0 +1,179 @@
import { RadioPlayer } from '@/core/RadioPlayer'
import { AudioData } from '@/types/audio'
import { formatTime, formatFrequency, frequencyToDb, dbToPercent } from '@/utils/formatters'
import './styles.css'
class GadgetRadioApp {
private player: RadioPlayer | null = null
private animationFrameId: number | null = null
private glitchTimeoutId: number | null = null
// DOM element references - all declared in index.html
private readonly elements = {
fileUploadBtn: document.getElementById('file-upload-btn') as HTMLButtonElement,
fileInput: document.getElementById('file-input') as HTMLInputElement,
freqBass: document.getElementById('freq-bass') as HTMLElement,
freqMids: document.getElementById('freq-mids') as HTMLElement,
freqTreble: document.getElementById('freq-treble') as HTMLElement,
volume: document.getElementById('volume') as HTMLElement,
volumeBar: document.getElementById('volume-bar') as HTMLElement,
fftSize: document.getElementById('fft-size') as HTMLElement,
sampleRate: document.getElementById('sample-rate') as HTMLElement,
channels: document.getElementById('channels') as HTMLElement,
currentTime: document.getElementById('current-time') as HTMLElement,
duration: document.getElementById('duration') as HTMLElement,
status: document.getElementById('status') as HTMLElement,
trackName: document.getElementById('track-name') as HTMLElement,
uploadContainer: document.getElementById('upload-container') as HTMLElement,
}
constructor() {
this.init()
}
private init(): void {
this.setupFileInput()
this.startTimeUpdate()
}
private setupFileInput(): void {
const { fileInput, fileUploadBtn } = this.elements
// Wire the static button from index.html to trigger the hidden file input
fileUploadBtn.addEventListener('click', () => {
fileInput.click()
})
// Handle file selection
fileInput.addEventListener('change', async (e) => {
const target = e.target as HTMLInputElement
const file = target.files?.[0]
if (!file) return
await this.loadAudioFile(file)
// Reset input so the same file can be selected again
target.value = ''
})
}
private async loadAudioFile(file: File): Promise<void> {
const canvasContainer = document.getElementById('canvas-container')
if (!canvasContainer) {
console.error('Canvas container not found')
return
}
try {
this.updateStatus('LOADING...')
this.player = new RadioPlayer(canvasContainer, undefined, {
onAudioDataUpdate: (data) => this.updateAudioDisplay(data),
onStateChange: (state) => this.handleStateChange(state),
})
await this.player.loadTrack(file)
if (this.elements.trackName) {
this.elements.trackName.textContent = file.name
}
// Fade out and hide the upload UI
this.elements.uploadContainer.style.opacity = '0'
setTimeout(() => {
this.elements.uploadContainer.style.display = 'none'
}, 500)
// Set audio context info
const analyser = this.player.getAnalyser()
if (analyser) {
if (this.elements.fftSize) {
this.elements.fftSize.textContent = analyser.frequencyBinCount.toString()
}
if (this.elements.sampleRate) {
this.elements.sampleRate.textContent = `${analyser.context.sampleRate} Hz`
}
if (this.elements.channels) {
this.elements.channels.textContent = '2 (stereo)'
}
}
await this.player.play()
this.updateStatus('STREAMING')
} catch (error) {
console.error('Failed to load audio:', error)
this.updateStatus('ERROR: LOAD FAILED')
}
}
private updateAudioDisplay(data: AudioData): void {
const { freqBass, freqMids, freqTreble, volume, volumeBar } = this.elements
if (freqBass) freqBass.textContent = formatFrequency(data.bass)
if (freqMids) freqMids.textContent = formatFrequency(data.mids)
if (freqTreble) freqTreble.textContent = formatFrequency(data.treble)
if (volume) {
const db = frequencyToDb(data.volume)
volume.textContent = db > -Infinity ? `${db.toFixed(1)} dB` : '-∞ dB'
}
if (volumeBar) {
const db = frequencyToDb(data.volume)
const percent = db > -Infinity ? dbToPercent(db) : 0
volumeBar.style.width = `${percent}%`
}
}
private handleStateChange(state: {
isPlaying: boolean
isLooping: boolean
isGlitching?: boolean
}): void {
if (state.isGlitching) {
// Clear any pending glitch timeout to avoid conflicts
if (this.glitchTimeoutId !== null) {
clearTimeout(this.glitchTimeoutId)
}
document.body.classList.add('glitch-active')
this.glitchTimeoutId = window.setTimeout(() => {
document.body.classList.remove('glitch-active')
this.glitchTimeoutId = null
}, 450)
}
this.updateStatus(state.isPlaying ? 'STREAMING' : 'PAUSED')
}
private updateStatus(status: string): void {
if (this.elements.status) {
this.elements.status.textContent = `>> SYSTEM: ${status}`
}
}
private startTimeUpdate(): void {
const updateTime = () => {
if (this.player) {
if (this.elements.currentTime) {
this.elements.currentTime.textContent = formatTime(this.player.getCurrentTime())
}
if (this.elements.duration) {
this.elements.duration.textContent = formatTime(this.player.getDuration())
}
}
this.animationFrameId = requestAnimationFrame(updateTime)
}
updateTime()
}
public dispose(): void {
if (this.animationFrameId !== null) {
cancelAnimationFrame(this.animationFrameId)
}
if (this.glitchTimeoutId !== null) {
clearTimeout(this.glitchTimeoutId)
}
this.player?.dispose()
}
}
// Initialize once the DOM is ready
new GadgetRadioApp()

172
src/styles.css Normal file
View File

@ -0,0 +1,172 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
background: #000;
overflow: hidden;
font-family: 'Courier New', monospace;
}
}
@layer components {
/* Scanlines Overlay */
.scanlines-overlay {
@apply fixed inset-0 pointer-events-none z-10;
}
.scanlines-overlay::before {
content: '';
@apply absolute inset-0 pointer-events-none;
background: repeating-linear-gradient(
0deg,
rgba(0, 0, 0, 0.4) 0px,
rgba(0, 0, 0, 0.4) 1px,
transparent 1px,
transparent 3px
);
}
.scanlines-overlay::after {
content: '';
@apply absolute inset-0 pointer-events-none;
background: radial-gradient(
ellipse at center,
rgba(0, 255, 255, 0.06) 0%,
transparent 70%
);
}
/* RGB Shift Overlay */
.rgb-shift-overlay {
@apply fixed inset-0 pointer-events-none z-10 transition-opacity duration-200;
opacity: 0.12;
background: linear-gradient(
90deg,
transparent 48%,
rgba(255, 0, 60, 0.08) 49%,
rgba(0, 255, 255, 0.08) 51%,
transparent 52%
);
background-size: 200% 100%;
}
/* Data Panel */
.data-panel {
@apply fixed p-3 text-cyber-green z-20 pointer-events-none overflow-hidden;
text-shadow:
0 0 8px #00ffff,
0 0 16px #00ffff;
font-size: 12px;
line-height: 1.6;
background: rgba(0, 0, 0, 0.55);
border: 1px solid rgba(0, 255, 255, 0.12);
border-radius: 3px;
backdrop-filter: blur(2px);
}
.data-top-left {
@apply top-4 left-4;
}
.data-top-right {
@apply top-4 right-4 text-right;
}
.data-bottom-left {
@apply bottom-4 left-4;
}
.data-bottom-right {
@apply bottom-4 right-4 text-right;
}
/* Upload Button */
.upload-button {
@apply bg-cyber-red text-black px-10 py-5 text-lg font-bold border-4 border-cyber-red;
box-shadow:
0 0 30px #ff003c,
inset 0 0 30px #ff003c;
cursor: pointer;
transition: all 0.3s;
animation: pulse 2s infinite;
}
.upload-button:hover {
@apply scale-110;
}
.upload-button:active {
@apply scale-95;
}
/* Progress Bar */
.progress-bar {
@apply inline-block w-44 h-2.5 relative overflow-hidden;
background: rgba(0, 255, 255, 0.15);
border: 1px solid rgba(0, 255, 255, 0.4);
}
.progress-fill {
@apply absolute left-0 top-0 bottom-0 transition-all duration-75;
background: #00d178;
box-shadow: 0 0 10px #00ffff;
}
/* Status Text */
.status-text {
@apply text-cyber-lime;
text-shadow: 0 0 10px #39ff14;
}
}
/* Animations */
@keyframes pulse {
0%, 100% {
box-shadow:
0 0 30px #ff003c,
inset 0 0 30px #ff003c;
}
50% {
box-shadow:
0 0 50px #ff003c,
inset 0 0 50px #ff003c;
}
}
@keyframes glitch {
0% {
transform: translate(0);
}
5% {
transform: translate(-2px, 2px);
}
10% {
transform: translate(2px, -2px);
}
15% {
transform: translate(-2px, -2px);
}
20% {
transform: translate(0);
}
100% {
transform: translate(0);
}
}
.glitch-active {
animation: glitch 0.15s ease-in-out 3;
}
/* Visualizer Active State */
.visualizer-active .upload-container {
@apply opacity-0 pointer-events-none;
}

40
src/types/audio.ts Normal file
View File

@ -0,0 +1,40 @@
export interface AudioData {
bass: number
mids: number
treble: number
volume: number
frequencyData?: Uint8Array
}
export interface VisualizerState {
isPlaying: boolean
isLooping: boolean
duration: number
currentTime: number
}
export interface GlitchState {
isGlitching: boolean
}
export type RadioState = VisualizerState & Partial<GlitchState>
export interface AppConfig {
fftSize: number
smoothingTimeConstant: number
minDecibels: number
maxDecibels: number
bassRange: [number, number]
midsRange: [number, number]
trebleRange: [number, number]
}
export const DEFAULT_CONFIG: AppConfig = {
fftSize: 2048,
smoothingTimeConstant: 0.8,
minDecibels: -90,
maxDecibels: -10,
bassRange: [20, 150],
midsRange: [150, 2000],
trebleRange: [2000, 8000],
}

33
src/utils/formatters.ts Normal file
View File

@ -0,0 +1,33 @@
export function formatTime(seconds: number): string {
if (!isFinite(seconds) || isNaN(seconds)) return '—'
const mins = Math.floor(seconds / 60)
const secs = Math.floor(seconds % 60)
return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`
}
export function formatFrequency(hz: number): string {
if (hz >= 1000) {
return `${(hz / 1000).toFixed(1)} kHz`
}
return `${Math.round(hz)} Hz`
}
export function frequencyToDb(value: number, maxValue: number = 255): number {
if (value <= 0) return -Infinity
const normalized = value / maxValue
if (normalized <= 0) return -Infinity
return 20 * Math.log10(normalized)
}
export function dbToPercent(db: number): number {
const minDb = -60
const maxDb = 0
const clamped = Math.max(minDb, Math.min(maxDb, db))
return ((clamped - minDb) / (maxDb - minDb)) * 100
}
export function formatDuration(seconds: number): string {
if (!isFinite(seconds) || isNaN(seconds)) return '—'
return formatTime(seconds)
}

View File

@ -0,0 +1,408 @@
import * as THREE from 'three'
import { EffectComposer } from 'three/examples/jsm/postprocessing/EffectComposer.js'
import { RenderPass } from 'three/examples/jsm/postprocessing/RenderPass.js'
import { UnrealBloomPass } from 'three/examples/jsm/postprocessing/UnrealBloomPass.js'
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js'
export interface VisualizerConfig {
container: HTMLElement
onGlitch?: () => void
}
interface FrequencyBand {
mesh: THREE.Mesh | THREE.Points
getScale: () => number
getPosition: () => number
setScale: (s: number) => void
}
export class AudioVisualizer {
private scene!: THREE.Scene
private camera!: THREE.PerspectiveCamera
private renderer!: THREE.WebGLRenderer
private composer!: EffectComposer
private controls!: OrbitControls
private analyser: AnalyserNode | null = null
private frequencyData: Uint8Array | null = null
private animationId: number | null = null
private config: VisualizerConfig
private isGlitching = false
private clock = new THREE.Clock()
// Objects
private icosahedron!: THREE.Mesh
private torus!: THREE.Mesh
private cubes: THREE.Mesh[] = []
private particles!: THREE.Points
private ring!: THREE.Mesh
private gridHelper!: THREE.GridHelper
private ringGroup!: THREE.Group
// Frequency bands for visual elements
private bassBand!: FrequencyBand
private midBand!: FrequencyBand
private trebleBand!: FrequencyBand
// Audio frequency values (smoothed)
private bassValue = 0
private midsValue = 0
private trebleValue = 0
constructor(config: VisualizerConfig) {
this.config = config
this.init()
}
private init(): void {
this.setupRenderer()
this.setupScene()
this.setupCamera()
this.setupControls()
this.setupPostProcessing()
this.setupGeometry()
this.setupEventListeners()
this.animate()
}
private setupRenderer(): void {
this.renderer = new THREE.WebGLRenderer({ antialias: true, alpha: false })
this.renderer.setSize(window.innerWidth, window.innerHeight)
this.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2))
this.renderer.toneMapping = THREE.ReinhardToneMapping
this.renderer.toneMappingExposure = 1.5
this.config.container.appendChild(this.renderer.domElement)
}
private setupScene(): void {
this.scene = new THREE.Scene()
this.scene.fog = new THREE.FogExp2(0x000000, 0.03)
}
private setupCamera(): void {
this.camera = new THREE.PerspectiveCamera(
60,
window.innerWidth / window.innerHeight,
0.1,
1000
)
this.camera.position.set(0, 5, 15)
this.camera.lookAt(0, 0, 0)
}
private setupControls(): void {
this.controls = new OrbitControls(this.camera, this.renderer.domElement)
this.controls.enableDamping = true
this.controls.dampingFactor = 0.05
this.controls.maxDistance = 50
this.controls.minDistance = 5
this.controls.autoRotate = true
this.controls.autoRotateSpeed = 0.5
}
private setupPostProcessing(): void {
this.composer = new EffectComposer(this.renderer)
const renderPass = new RenderPass(this.scene, this.camera)
this.composer.addPass(renderPass)
const bloomPass = new UnrealBloomPass(
new THREE.Vector2(window.innerWidth, window.innerHeight),
1.5, // strength
0.4, // radius
0.85 // threshold
)
this.composer.addPass(bloomPass)
}
private setupGeometry(): void {
// ── Grid ──────────────────────────────────────────────────────────────
this.gridHelper = new THREE.GridHelper(60, 60, 0x00ffff, 0x003333)
this.gridHelper.position.y = -3
this.scene.add(this.gridHelper)
// ── Icosahedron (center, bass-reactive) ───────────────────────────────
const icoGeo = new THREE.IcosahedronGeometry(2.5, 2)
const icoMat = new THREE.MeshBasicMaterial({
color: 0x00ffff,
wireframe: true,
})
this.icosahedron = new THREE.Mesh(icoGeo, icoMat)
this.scene.add(this.icosahedron)
// ── Cubes ring (mids-reactive) ───────────────────────────────────────
const cubeCount = 16
for (let i = 0; i < cubeCount; i++) {
const size = 0.2 + Math.random() * 0.4
const geo = new THREE.BoxGeometry(size, size, size)
const hue = (i / cubeCount) * 0.3
const mat = new THREE.MeshBasicMaterial({
color: new THREE.Color().setHSL(hue, 1, 0.5),
wireframe: true,
transparent: true,
opacity: 0.8,
})
const cube = new THREE.Mesh(geo, mat)
const angle = (i / cubeCount) * Math.PI * 2
const radius = 8
cube.position.set(
Math.cos(angle) * radius,
(Math.random() - 0.5) * 2,
Math.sin(angle) * radius
)
cube.userData.baseY = cube.position.y
cube.userData.angle = angle
cube.userData.radius = radius
this.scene.add(cube)
this.cubes.push(cube)
}
// ── Torus (treble-reactive) ───────────────────────────────────────────
const torusGeo = new THREE.TorusGeometry(5, 0.08, 8, 64)
const torusMat = new THREE.MeshBasicMaterial({ color: 0xff003c, wireframe: true })
this.torus = new THREE.Mesh(torusGeo, torusMat)
this.torus.rotation.x = Math.PI / 2
this.scene.add(this.torus)
// ── Inner ring ───────────────────────────────────────────────────────
const ringGeo = new THREE.TorusGeometry(3.5, 0.05, 8, 48)
const ringMat = new THREE.MeshBasicMaterial({ color: 0x00ff88, wireframe: true })
this.ring = new THREE.Mesh(ringGeo, ringMat)
this.ring.rotation.x = Math.PI / 2
this.scene.add(this.ring)
// ── Particle system ───────────────────────────────────────────────────
const particleCount = 2000
const positions = new Float32Array(particleCount * 3)
const colors = new Float32Array(particleCount * 3)
for (let i = 0; i < particleCount; i++) {
const i3 = i * 3
// Distribute in a sphere
const radius = 10 + Math.random() * 20
const theta = Math.random() * Math.PI * 2
const phi = Math.acos(2 * Math.random() - 1)
positions[i3] = radius * Math.sin(phi) * Math.cos(theta)
positions[i3 + 1] = radius * Math.sin(phi) * Math.sin(theta)
positions[i3 + 2] = radius * Math.cos(phi)
// Cyan to magenta gradient
const t = Math.random()
const color = new THREE.Color().setHSL(0.5 + t * 0.5, 1, 0.5)
colors[i3] = color.r
colors[i3 + 1] = color.g
colors[i3 + 2] = color.b
}
const particleGeo = new THREE.BufferGeometry()
particleGeo.setAttribute('position', new THREE.BufferAttribute(positions, 3))
particleGeo.setAttribute('color', new THREE.BufferAttribute(colors, 3))
const particleMat = new THREE.PointsMaterial({
size: 0.15,
vertexColors: true,
transparent: true,
opacity: 0.6,
blending: THREE.AdditiveBlending,
})
this.particles = new THREE.Points(particleGeo, particleMat)
this.scene.add(this.particles)
// ── Ring group for nested rotation ────────────────────────────────────
this.ringGroup = new THREE.Group()
this.scene.add(this.ringGroup)
this.ringGroup.add(this.torus)
this.ringGroup.add(this.ring)
// ── Frequency bands ───────────────────────────────────────────────────
this.bassBand = {
mesh: this.icosahedron,
getScale: () => 1 + this.bassValue / 128,
getPosition: () => 0,
setScale: () => {
const s = 1 + this.bassValue / 128
this.icosahedron.scale.setScalar(s)
},
}
this.midBand = {
mesh: this.cubes[0],
getScale: () => 1 + this.midsValue / 128,
getPosition: () => 0,
setScale: () => {
this.cubes.forEach((cube) => {
const scale = 0.8 + (this.midsValue / 128) * 1.5
cube.scale.setScalar(scale)
})
},
}
this.trebleBand = {
mesh: this.torus,
getScale: () => 1 + this.trebleValue / 128,
getPosition: () => 0,
setScale: () => {
const scale = 1 + (this.trebleValue / 128) * 0.5
this.torus.scale.set(scale, scale, scale)
this.ring.scale.set(scale, scale, scale)
},
}
}
private setupEventListeners(): void {
window.addEventListener('resize', this.onResize)
}
private onResize = (): void => {
const w = window.innerWidth
const h = window.innerHeight
this.camera.aspect = w / h
this.camera.updateProjectionMatrix()
this.renderer.setSize(w, h)
this.composer.setSize(w, h)
}
connectAnalyser(analyser: AnalyserNode): void {
this.analyser = analyser
this.frequencyData = new Uint8Array(analyser.frequencyBinCount)
}
private triggerGlitch(): void {
if (this.isGlitching) return
this.isGlitching = true
this.config.onGlitch?.()
document.body.classList.add('glitch-active')
setTimeout(() => {
document.body.classList.remove('glitch-active')
this.isGlitching = false
}, 450)
}
private animate = (): void => {
this.animationId = requestAnimationFrame(this.animate)
const elapsed = this.clock.getElapsedTime()
this.controls.update()
// Update frequency data
if (this.frequencyData && this.analyser) {
this.analyser.getByteFrequencyData(this.frequencyData as Uint8Array<ArrayBuffer>)
const { bass, mids, treble } = this.getFrequencyBands()
// Smooth the values
this.bassValue = THREE.MathUtils.lerp(this.bassValue, bass, 0.15)
this.midsValue = THREE.MathUtils.lerp(this.midsValue, mids, 0.15)
this.trebleValue = THREE.MathUtils.lerp(this.trebleValue, treble, 0.15)
// Update visual elements
this.bassBand.setScale(this.bassBand.getScale())
this.midBand.setScale(this.midBand.getScale())
this.trebleBand.setScale(this.trebleBand.getScale())
// Random glitch
if (Math.random() < 0.003) {
this.triggerGlitch()
}
}
// Animate icosahedron rotation
this.icosahedron.rotation.x += 0.003
this.icosahedron.rotation.y += 0.005
// Animate color based on bass
const hue = (elapsed * 0.05) % 1
const mat = this.icosahedron.material as THREE.MeshBasicMaterial
mat.color.setHSL(hue, 1, 0.5)
// Animate cubes
this.cubes.forEach((cube, i) => {
const angle = cube.userData.angle + elapsed * 0.2
const radius = cube.userData.radius + this.midsValue / 32
cube.position.x = Math.cos(angle) * radius
cube.position.z = Math.sin(angle) * radius
cube.position.y = cube.userData.baseY + Math.sin(elapsed * 2 + i) * 0.5
cube.rotation.x += 0.01
cube.rotation.y += 0.015
})
// Animate torus ring
this.ringGroup.rotation.z = elapsed * 0.1
// Animate particles
this.particles.rotation.y += 0.0003
this.particles.rotation.x += 0.0001
// Pulse ring color
const ringMat = this.ring.material as THREE.MeshBasicMaterial
ringMat.color.setHSL(0.4 + Math.sin(elapsed) * 0.1, 1, 0.5)
this.composer.render()
}
private getFrequencyBands(): { bass: number; mids: number; treble: number } {
if (!this.frequencyData || !this.analyser) {
return { bass: 0, mids: 0, treble: 0 }
}
const sampleRate = this.analyser.context.sampleRate
const binCount = this.analyser.frequencyBinCount
const maxFreq = sampleRate / 2
const getAverage = (lowHz: number, highHz: number): number => {
const startBin = Math.floor((lowHz / maxFreq) * binCount)
const endBin = Math.floor((highHz / maxFreq) * binCount)
let sum = 0
for (let i = startBin; i <= endBin && i < binCount; i++) {
sum += this.frequencyData![i]
}
return sum / Math.max(1, endBin - startBin + 1)
}
return {
bass: getAverage(20, 150),
mids: getAverage(150, 2000),
treble: getAverage(2000, 8000),
}
}
public getAudioData(): { bass: number; mids: number; treble: number; volume: number } {
const bands = this.getFrequencyBands()
const volume =
this.frequencyData
? this.frequencyData.reduce((a, b) => a + b, 0) / this.frequencyData.length
: 0
return { ...bands, volume }
}
public dispose(): void {
if (this.animationId !== null) {
cancelAnimationFrame(this.animationId)
}
window.removeEventListener('resize', this.onResize)
this.renderer.dispose()
this.composer.dispose()
this.scene.traverse((object) => {
if (object instanceof THREE.Mesh) {
object.geometry.dispose()
if (Array.isArray(object.material)) {
object.material.forEach((m) => m.dispose())
} else {
object.material.dispose()
}
}
if (object instanceof THREE.Points) {
object.geometry.dispose()
;(object.material as THREE.Material).dispose()
}
})
this.config.container.removeChild(this.renderer.domElement)
}
}

34
tailwind.config.js Normal file
View File

@ -0,0 +1,34 @@
/** @type {import('tailwindcss').Config} */
export default {
content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'],
theme: {
extend: {
colors: {
cyber: {
cyan: '#00ffff',
red: '#ff003c',
green: '#00d178',
lime: '#39ff14',
dark: '#000000',
},
},
fontFamily: {
mono: ['"Courier New"', 'Courier', 'monospace'],
},
animation: {
pulse: 'pulse 2s infinite',
glitch: 'glitch 0.15s ease-in-out 3',
},
keyframes: {
glitch: {
'0%': { transform: 'translate(0)' },
'5%': { transform: 'translate(-2px, 2px)' },
'10%': { transform: 'translate(2px, -2px)' },
'15%': { transform: 'translate(-2px, -2px)' },
'20%, 100%': { transform: 'translate(0)' },
},
},
},
},
plugins: [],
}

29
tsconfig.json Normal file
View File

@ -0,0 +1,29 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"module": "ESNext",
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
/* Paths */
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
}
},
"include": ["src"]
}

18
vite.config.ts Normal file
View File

@ -0,0 +1,18 @@
import { defineConfig } from 'vite'
import { resolve } from 'path'
export default defineConfig({
resolve: {
alias: {
'@': resolve(__dirname, 'src'),
},
},
server: {
port: 3000,
open: true,
},
build: {
outDir: 'dist',
sourcemap: true,
},
})