created
This commit is contained in:
parent
09e2510885
commit
769221948d
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
dist
|
||||||
|
node_modules
|
||||||
66
index.html
Normal file
66
index.html
Normal 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
2179
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
23
package.json
Normal file
23
package.json
Normal 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
1280
pnpm-lock.yaml
Normal file
File diff suppressed because it is too large
Load Diff
2
pnpm-workspace.yaml
Normal file
2
pnpm-workspace.yaml
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
allowBuilds:
|
||||||
|
esbuild: true
|
||||||
6
postcss.config.js
Normal file
6
postcss.config.js
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
}
|
||||||
132
src/audio/AudioEngine.ts
Normal file
132
src/audio/AudioEngine.ts
Normal 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
130
src/core/RadioPlayer.ts
Normal 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
179
src/main.ts
Normal 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
172
src/styles.css
Normal 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
40
src/types/audio.ts
Normal 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
33
src/utils/formatters.ts
Normal 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)
|
||||||
|
}
|
||||||
408
src/visualizer/AudioVisualizer.ts
Normal file
408
src/visualizer/AudioVisualizer.ts
Normal 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
34
tailwind.config.js
Normal 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
29
tsconfig.json
Normal 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
18
vite.config.ts
Normal 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,
|
||||||
|
},
|
||||||
|
})
|
||||||
Loading…
Reference in New Issue
Block a user