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