diff --git a/CHANGELOG.md b/CHANGELOG.md index 0593b3d..b387b00 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,22 @@ # Changelog +## [0.18.0] - 2026-05-23 + +### Added +- Skins system – switch the entire UI between **Modern**, **Win95**, **Winamp**, and **CRT** themes. Selectable via the `?skin=` URL parameter (e.g. `?skin=winamp`); the active skin is persisted to the URL alongside the other settings. +- ASCII visualizer – Canvas 2D audio-reactive ASCII art renderer. +- Cyber City visualizer – raymarched neon-grid cityscape flythrough with audio-driven scan pulses, fog, and dot density (Three.js/WebGL). +- Audio Debug visualizer – diagnostic view with waveform, FFT spectrum, kick detection, plus stereo correlation meter and rolling spectrogram (Canvas 2D). +- Aurum Leaf visualizer – tentacled energy bloom with particles, kick-reactive bloom bursts, and UnrealBloom post-processing (Three.js/WebGL). +- Anunaki Sphere visualizer – raymarched KIFS-folded sphere with auto-rotation and audio-reactive brightness/zoom (Three.js/WebGL). +- Trails Stream visualizer – bending tube-trail stream with blur, bloom, and audio-reactive exposure (Three.js/WebGL). +- Shambhala visualizer – voxel tunnel raymarcher with space-folding, glow, and audio-reactive exposure (Three.js/WebGL). + +### Changed +- FractalOrb: refactored audio analysis and visual response for tighter reactivity. +- Vite: raised `chunkSizeWarningLimit` from 550 to 600 to accommodate the new shader-heavy visualizers. +- Dependency bumps + ## [0.17.0] - 2026-05-13 ### Added diff --git a/README.md b/README.md index 3c1f699..3f90143 100644 --- a/README.md +++ b/README.md @@ -13,18 +13,20 @@ [![Voltviz](images/voltviz.png)](https://voltviz.com) --- -VoltViz comes with **40+ stunning visualization styles** to choose from: +VoltViz comes with **50+ stunning visualization styles** to choose from: -- **Particle Effects**: Cosmic Particles, Fireworks Show -- **Abstract Patterns**: Cyber Matrix, Cyber Grid Canvas, Neon Hex Tunnel, Neon Wave, Aurora Waves -- **3D Visualizations**: Poly Sphere, Glow Sphere, 3D Equalizer, Hex Globe, Fractal Orb -- **Retro Styles**: CRT Terminal, Vinyl, VU Meter, Sheet Music, Glitch Background, Glitch Databend, MS Defrag +- **Particle Effects**: Cosmic Particles, Fireworks Show, Trails Stream +- **Abstract Patterns**: Cyber Matrix, Cyber Grid Canvas, Cyber City, Neon Hex Tunnel, Neon Wave, Aurora Waves, Shambhala +- **3D Visualizations**: Poly Sphere, Glow Sphere, 3D Equalizer, Hex Globe, Fractal Orb, Anunaki Sphere, Aurum Leaf +- **Retro Styles**: CRT Terminal, ASCII, Vinyl, VU Meter, Sheet Music, Glitch Background, Glitch Databend, MS Defrag - **Festival Vibes**: Festival Stage, Defqon Mainstage, Disney Drone Show - **Organic Effects**: Fluid Smoke, Ghost Rainbow, Psychedelic Skull, Flame -- **Data Driven**: Dutch Grid, Dutch Grid (WebGL), Data Dashboard +- **Data Driven**: Dutch Grid, Dutch Grid (WebGL), Data Dashboard, Audio Debug - **MilkDrop-inspired**: MilkDrop, MilkDrop Warp - **And many more**: Bars, Circular, Tunnel, Background Image, Blur Image, Your Logo, Icons, and Sendspin variants... +**UI Skins:** Switch the entire interface between **Modern**, **Win95**, **Winamp**, and **CRT** themes via the `?skin=` URL parameter. + **Core Capabilities:** - 🎤 **Real-time Audio Input**: Connect microphone, capture system audio, or stream from a [Sendspin](https://www.sendspin-audio.com) server - 📊 **High-Performance Rendering**: GPU-accelerated with Three.js and WebGL @@ -152,6 +154,7 @@ http://localhost:8080/?viz=tunnel&sensitivity=1.5&speed=2.0&hueShift=180&scale=1 | `speed` | Animation speed multiplier (0.1–3.0) | `1.0` | | `hueShift` | Color shift in degrees (0–360) | `0` | | `scale` | Element scale multiplier (0.5–3.0) | `1.0` | +| `skin` | UI theme: `modern`, `win95`, `winamp`, or `crt` | `modern` | The URL updates automatically as you change the visualizer or adjust settings in the UI, so you can share or bookmark your current configuration at any time. Only non-default settings are included to keep URLs clean. diff --git a/package-lock.json b/package-lock.json index 4bf3b7b..707fd70 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "voltviz", - "version": "0.17.0", + "version": "0.18.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "voltviz", - "version": "0.17.0", + "version": "0.18.0", "dependencies": { "@sendspin/sendspin-js": "^3.1.0", "d3-geo": "^3.1.1", @@ -140,9 +140,9 @@ } }, "node_modules/@oxc-project/types": { - "version": "0.129.0", - "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.129.0.tgz", - "integrity": "sha512-3oz8m3FGdr2nDXVqmFUw7jolKliC4MoyXYIG2c7gpjBnzUWQpUGIYcXYKxTdTi+N2jusvt610ckTMkxdwHkYEg==", + "version": "0.132.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.132.0.tgz", + "integrity": "sha512-FESMOxil5Se014ui/Eq8fT5uHJo6nIRwH0PfJrZJXs6Gek3ZVFOrpUv3YIZT20m+extU98Hg1Ym72U58rlsxUQ==", "dev": true, "license": "MIT", "funding": { @@ -166,9 +166,9 @@ } }, "node_modules/@rolldown/binding-android-arm64": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0.tgz", - "integrity": "sha512-TWMZnRLMe63C2Lhyicviu7ZHaU4kxa6PS3rofvc9GmcvptzNN11BcfQ4Sl7MwTOsisQoa2keB/EBdNCAnUo8vA==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.2.tgz", + "integrity": "sha512-ZS4D1JPGn/MYQN/SYDWftIE/nVsM8j/AFOYEzAoOE2O3NktQOZru+/vYXGbR/qtdLdIfGCP0lcoJiYVzsEz+iQ==", "cpu": [ "arm64" ], @@ -183,9 +183,9 @@ } }, "node_modules/@rolldown/binding-darwin-arm64": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0.tgz", - "integrity": "sha512-6XcD+8k0gPVItNagEw78/qqcBDwKcwDYS8V2hRmVsfUSIrd8cWe/CBvRDI5toqFyPfj+FJr6t8U6Xj2P2prEew==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.2.tgz", + "integrity": "sha512-vdFA9+C/rekyGce7WqHs/xoT0ioZEWaOFyZLIV1mEeNFaFDUQrPIo8Vs2GvJ6eetb3rzDUtUBgzto3ExpXJB3w==", "cpu": [ "arm64" ], @@ -200,9 +200,9 @@ } }, "node_modules/@rolldown/binding-darwin-x64": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0.tgz", - "integrity": "sha512-iN/tWVXRQDWvmZlKdceP1Dwug9GDpEymhb9p4xnEe6zvCg5lFmzVljl+1qR1NVx3yfGpr2Na+CuLmv5IU8uzfQ==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.2.tgz", + "integrity": "sha512-BewSOwTHazv77DTYiAZXSqqKZ4KP/KonFisDMVU7PImxoWfB2aepnPhd2E4SWz3zDzYgDNbs6jBmTdgNnF02GA==", "cpu": [ "x64" ], @@ -217,9 +217,9 @@ } }, "node_modules/@rolldown/binding-freebsd-x64": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0.tgz", - "integrity": "sha512-jjQMDvvwSOuhOwMszD/klSOjyWMM3zI64hWTj9KT5x4MxRbZAf+7vLQ6qouRhtsLVFHr3f0ILaJAfgENPiQdAQ==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.2.tgz", + "integrity": "sha512-m41o7M0YWtUdqk61Tb+jnKb2rN++iRdIASlExkUoKfIAH30DOHCB8fVLzSUpbWHHU8esmEioY62PxzexE8MBuA==", "cpu": [ "x64" ], @@ -234,9 +234,9 @@ } }, "node_modules/@rolldown/binding-linux-arm-gnueabihf": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0.tgz", - "integrity": "sha512-d//Dtg2x6/m3mbV64yUGNnDGNZaDGRpDLLNGerHQUVObuNaIQaaDp25yUiqGXtHEXX+NP2d0wAlmKgpYgIAJ2A==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.2.tgz", + "integrity": "sha512-jcojB9H7W/jS29pMKWAK1N+fU99vXodHDTatS3b3y/XSOCiHo0kkA74pL3jJmkoQtYpOCxDvaKs1fo2Ij/1X5w==", "cpu": [ "arm" ], @@ -251,9 +251,9 @@ } }, "node_modules/@rolldown/binding-linux-arm64-gnu": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0.tgz", - "integrity": "sha512-n7Ofp0mx+aB2cC+Sdy5YtMnXtY9lchnHbY+3Yt0uq9JsWQExf4f5Whu0tK0R8Jdc9S6RchTHjIFY7uc92puOVQ==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.2.tgz", + "integrity": "sha512-1jn6qDU5iiOgFgygDzKUuKP0maTi0/f1+sBLgvij/76C77Nm3ts6ufz9Bjg5q5dduxiUIxtq86JIoBvo1xQ4Ig==", "cpu": [ "arm64" ], @@ -271,9 +271,9 @@ } }, "node_modules/@rolldown/binding-linux-arm64-musl": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0.tgz", - "integrity": "sha512-EIVjy2cgd7uuMMo94FVkBp7F6DhcZAUwNURkSG3RwUmvAXR6s0ISxM81U+IydcZByPG0pZIHsf1b6kTxoFDgJA==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.2.tgz", + "integrity": "sha512-QVLO/czFMdoMFSqlX3bcswcJNm/23r+qoa/jgtmFc/qEp6/jXmIkDjF/XIo8dPfGaiwy1xfQn8o77L79GeXFgw==", "cpu": [ "arm64" ], @@ -291,9 +291,9 @@ } }, "node_modules/@rolldown/binding-linux-ppc64-gnu": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0.tgz", - "integrity": "sha512-JEwwOPcwTLAcpDQlqSmjEmfs63xJnSiUNIGvLcDLUHCWK4XowpS/7c7tUsUH6uT/ct6bMUTdXKfI8967FYj6mg==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.2.tgz", + "integrity": "sha512-hgO5Abm0w5UL6FEa2iFnZqo2KlK7TQ5QhV5x09hujBf7t5KzHQ1VmfPuTpqRy/rNlSxua3eWH374xxiVrP+lcA==", "cpu": [ "ppc64" ], @@ -311,9 +311,9 @@ } }, "node_modules/@rolldown/binding-linux-s390x-gnu": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0.tgz", - "integrity": "sha512-0wjCFhLrihtAubnT9iA0N++0pSV0z5Hg7tNGdNJ4RFaINceHadoF+kiFGyY1qSSNVIAZtLotG8Ju1bgDPkjnFA==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.2.tgz", + "integrity": "sha512-fy8rXxuYEu602abC8MUNaPjYLIFzReOaEIEMKMUa0rFEUxNpVXhs15KSSQ4qlqSaM7B6rcj9rDZgADh/IGDzLQ==", "cpu": [ "s390x" ], @@ -331,9 +331,9 @@ } }, "node_modules/@rolldown/binding-linux-x64-gnu": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0.tgz", - "integrity": "sha512-Dfn7iak9BcMMePxcoJfpSbWqnEyrp/dRF63/8qW/eHBdOZov6x5aShLLEYGYdIeSJ6vMLK/XCVB+lGIxm41bQA==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.2.tgz", + "integrity": "sha512-0+bOkiQ779+r1WpoHOWHqncvyySci0vKph+myNDYb+im6meJAzHQXay6oEgnkHuUGouM1LKTZwqKpBow6Kj7CQ==", "cpu": [ "x64" ], @@ -351,9 +351,9 @@ } }, "node_modules/@rolldown/binding-linux-x64-musl": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0.tgz", - "integrity": "sha512-5/utzzDmD/pD/bmuaUcbTf/sZYy0aztwIVlfpoW1fTjCZ0BaPOMVWGZL1zvgxyi7ZIVYWlxKONHmSbHuiOh8Jw==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.2.tgz", + "integrity": "sha512-mjSkrzZK5Qsl0a9d1JgILOiuZOSDTVdKENcSXBoqbzSrspLR/4/IRVDo5wd2GgZjNss/viBFJdeq+j7qH2nypw==", "cpu": [ "x64" ], @@ -371,9 +371,9 @@ } }, "node_modules/@rolldown/binding-openharmony-arm64": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0.tgz", - "integrity": "sha512-ouJs8VcUomfLfpbUECqFMRqdV4x6aeAK3MA4m6vTrJJjKyWTV5KnxZx7Jd9G+GlDaQQxubcba00x16OyJ1meig==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.2.tgz", + "integrity": "sha512-1v5vHasdfQAZoEHakBV72LIFAC9JjnymsiKxp+GEr/ma3+NJCPSaYK+qavInOovJkgwFrs7GccX2d6IgDA3Z5w==", "cpu": [ "arm64" ], @@ -388,9 +388,9 @@ } }, "node_modules/@rolldown/binding-wasm32-wasi": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0.tgz", - "integrity": "sha512-E+oHKGiDA+lsKMmFtffDDw91EryDT7uJocrIuCHqhm6bCTM6xFK+3gaCkYOHfPwQr0cCNarSM2xaELoQDz9jJg==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.2.tgz", + "integrity": "sha512-mb1VobWn6NheziTk5/WEaR6AKVbrwT5sOi6C7zk3gy/pD1qtJfU1j4PgTo2NJnOtbL9Dl3Aeei8w9jJ7qC2jZQ==", "cpu": [ "wasm32" ], @@ -407,9 +407,9 @@ } }, "node_modules/@rolldown/binding-win32-arm64-msvc": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0.tgz", - "integrity": "sha512-yYK02n8Rngo+gbm1y6G0+7jk1sJ/2Wt7K0me0Y7k/ErBpyf+LJ2gFpqWVTcRV1rUepBlQRmpgWkTQCiiwrK0Ow==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.2.tgz", + "integrity": "sha512-SqKonF56vA/L2yHwHYcEp2P34URpOZ7d1fS635cTkpDnUtEGdUbhI6NzsPdqeSWvAAeGDrxjWjNmibDIdFf9/A==", "cpu": [ "arm64" ], @@ -424,9 +424,9 @@ } }, "node_modules/@rolldown/binding-win32-x64-msvc": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0.tgz", - "integrity": "sha512-14bpChMahXRRXiTwahSl+zzHPW6qQTXtkMuJBFlbo+pqSAews2d4BdCSHfrJ/MBsCZtpmTafsY+1QhBzitcmdg==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.2.tgz", + "integrity": "sha512-v7qRI7gXLRINcOGXt+7YmAZ6iFuyZVMIoXAxhd8oP+DR9dLfL9GfNIx7PLMxmhZdvq8waUJBQiWN9EKNy+TRBQ==", "cpu": [ "x64" ], @@ -441,9 +441,9 @@ } }, "node_modules/@rolldown/pluginutils": { - "version": "1.0.0-rc.7", - "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.7.tgz", - "integrity": "sha512-qujRfC8sFVInYSPPMLQByRh7zhwkGFS4+tyMQ83srV1qrxL4g8E2tyxVVyxd0+8QeBM1mIk9KbWxkegRr76XzA==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", "dev": true, "license": "MIT" }, @@ -779,19 +779,19 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "25.7.0", - "resolved": "https://registry.npmjs.org/@types/node/-/node-25.7.0.tgz", - "integrity": "sha512-z+pdZyxE+RTQE9AcboAZCb4otwcrvgHD+GlBpPgn0emDVt0ohrTMhAwlr2Wd9nZ+nihhYFxO2pThz3C5qSu2Eg==", + "version": "25.9.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.1.tgz", + "integrity": "sha512-xfrlY7UD5rMJk3ZVJP8BNzS28J36YJg+xp+LPXV1TdWxr8uMH5A860QNxYDGQe/ylDSgjxE52Q9VnO7p75tJxg==", "dev": true, "license": "MIT", "dependencies": { - "undici-types": "~7.21.0" + "undici-types": ">=7.24.0 <7.24.7" } }, "node_modules/@types/react": { - "version": "19.2.14", - "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", - "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", + "version": "19.2.15", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.15.tgz", + "integrity": "sha512-eRwcGNHve+E8qtEQSSRl6urh+rFop4v8gm6O8rGv25CodbvFdLjA1vVQ1KkiFE0w0UPOnb8tDiFKL5lp0rtY5Q==", "dev": true, "license": "MIT", "dependencies": { @@ -838,13 +838,13 @@ "license": "MIT" }, "node_modules/@vitejs/plugin-react": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.1.tgz", - "integrity": "sha512-l9X/E3cDb+xY3SWzlG1MOGt2usfEHGMNIaegaUGFsLkb3RCn/k8/TOXBcab+OndDI4TBtktT8/9BwwW8Vi9KUQ==", + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.2.tgz", + "integrity": "sha512-DlSMqo4WhThw4vB8Mpn0Woe9J+Jfq1geJ61AKW0QEgLzGMNwtIMdxbDUzLxcun8W7NbJO0e2Jg/Nxm3cCSVzzg==", "dev": true, "license": "MIT", "dependencies": { - "@rolldown/pluginutils": "1.0.0-rc.7" + "@rolldown/pluginutils": "^1.0.0" }, "engines": { "node": "^20.19.0 || >=22.12.0" @@ -905,9 +905,9 @@ } }, "node_modules/enhanced-resolve": { - "version": "5.21.3", - "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.21.3.tgz", - "integrity": "sha512-QyL119InA+XXEkNLNTPCXPugSvOfhwv0JOlGNzvxs0hZaiHLNvXSpudUWsOlsXGWJh8G6ckCScEkVHfX3kw/2Q==", + "version": "5.22.0", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.22.0.tgz", + "integrity": "sha512-xYcDWrpELkFzz9SpZ3PlI6Eu6eD93Yf0WLDRxikGhWJ3MAir2SNZTIVCVZqZ/NUyx8AdMc2gT9C0gPiw18kG+A==", "dev": true, "license": "MIT", "dependencies": { @@ -937,9 +937,9 @@ } }, "node_modules/fflate": { - "version": "0.8.2", - "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz", - "integrity": "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==", + "version": "0.8.3", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz", + "integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==", "dev": true, "license": "MIT" }, @@ -975,9 +975,9 @@ } }, "node_modules/jiti": { - "version": "2.6.1", - "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", - "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", + "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", "dev": true, "license": "MIT", "bin": { @@ -1127,6 +1127,9 @@ "arm64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MPL-2.0", "optional": true, "os": [ @@ -1148,6 +1151,9 @@ "arm64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MPL-2.0", "optional": true, "os": [ @@ -1169,6 +1175,9 @@ "x64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MPL-2.0", "optional": true, "os": [ @@ -1190,6 +1199,9 @@ "x64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MPL-2.0", "optional": true, "os": [ @@ -1246,9 +1258,9 @@ } }, "node_modules/lucide-react": { - "version": "1.14.0", - "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.14.0.tgz", - "integrity": "sha512-+1mdWcfSJVUsaTIjN9zoezmUhfXo5l0vP7ekBMPo3jcS/aIkxHnXqAPsByszMZx/Y8oQBRJxJx5xg+RH3urzxA==", + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.16.0.tgz", + "integrity": "sha512-dYwyPzb4MEKpGUmNYk3WKWPnMrHs3FKM+q94kAnJrcDIqqn1hq2xY8scaS2ovsOCM5D51ey2gaRG3PBb1vgoYQ==", "license": "ISC", "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" @@ -1349,9 +1361,9 @@ } }, "node_modules/postcss": { - "version": "8.5.14", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz", - "integrity": "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==", + "version": "8.5.15", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", + "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", "dev": true, "funding": [ { @@ -1369,7 +1381,7 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.11", + "nanoid": "^3.3.12", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, @@ -1399,14 +1411,14 @@ } }, "node_modules/rolldown": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0.tgz", - "integrity": "sha512-yD986aXDESFGS95spT1LAv0jssywP4npMEjmMHyN2/5+eE8qQJUype2AaKkRiLgBgyD0LFlubwAht7VmY8rGoA==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.2.tgz", + "integrity": "sha512-oZx5zVDtVB44AW3eaifgDml1gWRDZGvjcfdxonE4swNPG98PrrXjaO/KrnUjzlMnztCCRVlUueA1kCXhARGk6g==", "dev": true, "license": "MIT", "dependencies": { - "@oxc-project/types": "=0.129.0", - "@rolldown/pluginutils": "1.0.0" + "@oxc-project/types": "=0.132.0", + "@rolldown/pluginutils": "^1.0.0" }, "bin": { "rolldown": "bin/cli.mjs" @@ -1415,29 +1427,22 @@ "node": "^20.19.0 || >=22.12.0" }, "optionalDependencies": { - "@rolldown/binding-android-arm64": "1.0.0", - "@rolldown/binding-darwin-arm64": "1.0.0", - "@rolldown/binding-darwin-x64": "1.0.0", - "@rolldown/binding-freebsd-x64": "1.0.0", - "@rolldown/binding-linux-arm-gnueabihf": "1.0.0", - "@rolldown/binding-linux-arm64-gnu": "1.0.0", - "@rolldown/binding-linux-arm64-musl": "1.0.0", - "@rolldown/binding-linux-ppc64-gnu": "1.0.0", - "@rolldown/binding-linux-s390x-gnu": "1.0.0", - "@rolldown/binding-linux-x64-gnu": "1.0.0", - "@rolldown/binding-linux-x64-musl": "1.0.0", - "@rolldown/binding-openharmony-arm64": "1.0.0", - "@rolldown/binding-wasm32-wasi": "1.0.0", - "@rolldown/binding-win32-arm64-msvc": "1.0.0", - "@rolldown/binding-win32-x64-msvc": "1.0.0" - } - }, - "node_modules/rolldown/node_modules/@rolldown/pluginutils": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0.tgz", - "integrity": "sha512-aKs/3GSWyV0mrhNmt/96/Z3yczC3yvrzYATCiCXQebBsGyYzjNdUphRVLeJQ67ySKVXRfMxt2lm12pmXvbPFQQ==", - "dev": true, - "license": "MIT" + "@rolldown/binding-android-arm64": "1.0.2", + "@rolldown/binding-darwin-arm64": "1.0.2", + "@rolldown/binding-darwin-x64": "1.0.2", + "@rolldown/binding-freebsd-x64": "1.0.2", + "@rolldown/binding-linux-arm-gnueabihf": "1.0.2", + "@rolldown/binding-linux-arm64-gnu": "1.0.2", + "@rolldown/binding-linux-arm64-musl": "1.0.2", + "@rolldown/binding-linux-ppc64-gnu": "1.0.2", + "@rolldown/binding-linux-s390x-gnu": "1.0.2", + "@rolldown/binding-linux-x64-gnu": "1.0.2", + "@rolldown/binding-linux-x64-musl": "1.0.2", + "@rolldown/binding-openharmony-arm64": "1.0.2", + "@rolldown/binding-wasm32-wasi": "1.0.2", + "@rolldown/binding-win32-arm64-msvc": "1.0.2", + "@rolldown/binding-win32-x64-msvc": "1.0.2" + } }, "node_modules/scheduler": { "version": "0.27.0", @@ -1522,23 +1527,23 @@ } }, "node_modules/undici-types": { - "version": "7.21.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.21.0.tgz", - "integrity": "sha512-w9IMgQrz4O0YN1LtB7K5P63vhlIOvC7opSmouCJ+ZywlPAlO9gIkJ+otk6LvGpAs2wg4econaCz3TvQ9xPoyuQ==", + "version": "7.24.6", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz", + "integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==", "dev": true, "license": "MIT" }, "node_modules/vite": { - "version": "8.0.12", - "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.12.tgz", - "integrity": "sha512-w2dDofOWv2QB09ZITZBsvKTVAlYvPR4IAmrY/v0ir9KvLs0xybR7i48wxhM1/oyBWO34wPns+bPGw5ZrZqDpZg==", + "version": "8.0.14", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.14.tgz", + "integrity": "sha512-s4BJJ+5y1pYL6Otw51FHhVJQhPnuRinKig64g/1+EUNaJsd3gCKdD31IPFvswUgW9/60QT9oFHbZHbQK5imcxw==", "dev": true, "license": "MIT", "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", - "postcss": "^8.5.14", - "rolldown": "1.0.0", + "postcss": "^8.5.15", + "rolldown": "1.0.2", "tinyglobby": "^0.2.16" }, "bin": { diff --git a/package.json b/package.json index de9331a..0d86de8 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "voltviz", "private": true, - "version": "0.17.0", + "version": "0.18.0", "type": "module", "scripts": { "dev": "vite --port=3000 --host=0.0.0.0", diff --git a/src/App.tsx b/src/App.tsx index b4a1c1e..fad78c7 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -4,6 +4,7 @@ import { SendspinPlayer } from '@sendspin/sendspin-js'; import type { ServerStateMetadata, ControllerCommand, ControllerCommands } from '@sendspin/sendspin-js'; import githubIcon from './images/GitHub_Invertocat_White.svg'; import { VisualizerSettings } from './types'; +import { skins, SkinType } from './skins'; type VisualizerType = | 'circular' @@ -48,7 +49,14 @@ type VisualizerType = | 'msdefrag' | 'fractalorb' | 'mossball' - | 'razor1911'; + | 'razor1911' + | 'ascii' + | 'cybercity' + | 'audiodebug' + | 'aurumleaf' + | 'anunakisphere' + | 'trailsstream' + | 'shambhala'; type VisualizerProps = { stream: MediaStream; @@ -118,6 +126,13 @@ const visualizerComponents: Record import('./components/visualizers/FractalOrb')), mossball: lazy(() => import('./components/visualizers/MossBall')), razor1911: lazy(() => import('./components/visualizers/Razor1911')), + ascii: lazy(() => import('./components/visualizers/Ascii')), + cybercity: lazy(() => import('./components/visualizers/CyberCity')), + audiodebug: lazy(() => import('./components/visualizers/AudioDebug')), + aurumleaf: lazy(() => import('./components/visualizers/AurumLeaf')), + anunakisphere: lazy(() => import('./components/visualizers/AnunakiSphere')), + trailsstream: lazy(() => import('./components/visualizers/TrailsStream')), + shambhala: lazy(() => import('./components/visualizers/Shambhala')), }; export default function App() { @@ -150,6 +165,11 @@ export default function App() { scale: num('scale', 1.0), }; }); + const [activeSkin, setActiveSkin] = useState(() => { + const s = new URLSearchParams(window.location.search).get('skin'); + return s && s in skins ? (s as SkinType) : 'modern'; + }); + const skin = skins[activeSkin]; const [showSendspinDialog, setShowSendspinDialog] = useState(false); const [sendspinUrl, setSendspinUrl] = useState(''); const sendspinPlayerRef = useRef(null); @@ -179,9 +199,11 @@ export default function App() { setOrDelete('speed', settings.speed, 1.0); setOrDelete('hueShift', settings.hueShift, 0); setOrDelete('scale', settings.scale, 1.0); + if (activeSkin !== 'modern') params.set('skin', activeSkin); + else params.delete('skin'); const qs = params.toString(); window.history.replaceState(null, '', qs ? `${window.location.pathname}?${qs}` : window.location.pathname); - }, [activeVisualizer, settings]); + }, [activeVisualizer, settings, activeSkin]); useEffect(() => { // Allow layout to settle, then notify visualizers of the size change @@ -372,15 +394,15 @@ export default function App() { }, [stream, activeVisualizer, settings, sendspin.metadata]); return ( -
+
{/* Mobile hint */} -
+
Small screens are not supported, use "Desktopsite"
{/* Atmospheric background */} - {!stream && ( + {!stream && skin.atmosphericBg && (
@@ -389,11 +411,11 @@ export default function App() {
{showControls && ( -
+
-

VoltVizMusic Visualizer

-

inspired by winamp & sonique - created by sanderdw

+

VoltViz Music Visualizer

+

inspired by winamp - created by sanderdw

{stream && ( @@ -405,53 +427,60 @@ export default function App() { setActiveVisualizer(value); (window as any)._paq?.push(['trackEvent', 'Visualizer', 'Select', value]); }} - className="appearance-none bg-white/10 hover:bg-white/20 border border-white/10 rounded-full pl-4 pr-10 py-2 text-sm text-white focus:outline-none focus:ring-2 focus:ring-purple-500 cursor-pointer transition-colors" + className={skin.select} > - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - +
)}
@@ -461,31 +490,31 @@ export default function App() { href="https://github.com/sanderdw/voltviz" target="_blank" rel="noopener noreferrer" - className="p-2 rounded-full bg-white/5 hover:bg-white/10 transition-colors border border-white/5 text-white/70 hover:text-white" + className={activeSkin === 'win95' ? 'p-1.5 bg-[#c0c0c0] border-2 border-t-white border-l-white border-b-[#808080] border-r-[#808080]' : activeSkin === 'winamp' ? 'p-1 bg-[#3a3a4a] border-2 border-t-[#6a6a7a] border-l-[#6a6a7a] border-b-[#1a1a2a] border-r-[#1a1a2a]' : activeSkin === 'crt' ? 'p-1.5 border border-[#00ff00]/30 hover:border-[#00ff00]/60 hover:shadow-[0_0_8px_rgba(0,255,0,0.2)]' : 'p-2 rounded-full bg-white/5 hover:bg-white/10 transition-colors border border-white/5 text-white/70 hover:text-white'} title="GitHub" aria-label="Open GitHub profile" > - GitHub + GitHub {!stream ? ( <>
@@ -559,8 +588,8 @@ export default function App() {
- - {settings.sensitivity.toFixed(1)}x + + {settings.sensitivity.toFixed(1)}x
setSettings({...settings, sensitivity: parseFloat(e.target.value)})} - className="w-full accent-purple-500" + className={skin.settingsSlider} /> -

Adjusts how strongly the visualizer reacts to volume.

+

Adjusts how strongly the visualizer reacts to volume.

- - {settings.speed.toFixed(1)}x + + {settings.speed.toFixed(1)}x
setSettings({...settings, speed: parseFloat(e.target.value)})} - className="w-full accent-purple-500" + className={skin.settingsSlider} /> -

Controls the animation and movement speed.

+

Controls the animation and movement speed.

- - {settings.scale.toFixed(1)}x + + {settings.scale.toFixed(1)}x
setSettings({...settings, scale: parseFloat(e.target.value)})} - className="w-full accent-purple-500" + className={skin.settingsSlider} /> -

Scales the visualizer elements to fit the screen.

+

Scales the visualizer elements to fit the screen.

- - {settings.hueShift}° + + {settings.hueShift}°
setSettings({...settings, hueShift: parseInt(e.target.value)})} - className="w-full accent-purple-500" + className={skin.settingsSlider} /> -

Shifts the base colors across the spectrum.

+

Shifts the base colors across the spectrum.

-
+
v{appVersion}
@@ -642,7 +671,7 @@ export default function App() { {sendspin.active && showControls && (
-
+
{/* Track info */} {sendspin.metadata?.title && (
@@ -650,9 +679,9 @@ export default function App() { )}
-
{sendspin.metadata.title}
+
{sendspin.metadata.title}
{sendspin.metadata.artist && ( -
{sendspin.metadata.artist}
+
{sendspin.metadata.artist}
)}
@@ -663,7 +692,7 @@ export default function App() {
{/* Divider */} -
+
{/* Volume */}
{/* Divider */} -
+
{/* Shuffle & Repeat */}
-
-

Enter the URL of your Sendspin server to stream synchronized audio.

+
+
+ {activeSkin === 'win95' && ( +
+ Connect to Sendspin + +
+ )} + {activeSkin === 'winamp' && ( +
+

Connect to Sendspin

+ +
+ )} + {activeSkin === 'crt' && ( +
+

Connect to Sendspin

+ +
+ )} + {activeSkin === 'modern' && ( +
+

Connect to Sendspin

+ +
+ )} +

Enter the URL of your Sendspin server to stream synchronized audio.

setSendspinUrl(e.target.value)} onKeyDown={e => { if (e.key === 'Enter' && sendspinUrl) startSendspin(); }} placeholder="http://homeassistant.local:8927" - className="w-full bg-white/10 border border-white/10 rounded-lg px-4 py-2 text-white placeholder-white/30 focus:outline-none focus:ring-2 focus:ring-purple-500" + className={skin.dialogInput} autoFocus />
diff --git a/src/components/visualizers/AnunakiSphere.tsx b/src/components/visualizers/AnunakiSphere.tsx new file mode 100644 index 0000000..6edef34 --- /dev/null +++ b/src/components/visualizers/AnunakiSphere.tsx @@ -0,0 +1,365 @@ +import { useEffect, useRef } from 'react'; +import * as THREE from 'three'; +import { VisualizerSettings } from '../../types'; + +interface Props { + stream: MediaStream; + settings: VisualizerSettings; +} + +const vertexShader = ` + void main() { + gl_Position = vec4(position, 1.0); + } +`; + +const fragmentShader = ` + precision highp float; + + uniform vec2 uResolution; + uniform float uTime; + uniform int uMaxSteps; + uniform int uFoldSteps; + uniform float uKifsScale; + uniform float uKifsOffset; + uniform float uTextureScale; + uniform float uBrightness; + uniform float uSphereRadius; + uniform float uInnerRadius; + uniform vec3 uSphereCenter; + uniform vec3 uBaseColor; + uniform float uLightSpeed; + uniform float uAutoRotationSpeed; + uniform float uZoom; + + mat2 rotate2D(float angle) { + float s = sin(angle); + float c = cos(angle); + return mat2(c, -s, s, c); + } + + vec2 intersectSphere(vec3 rayOrigin, vec3 rayDir, vec3 center, float radius) { + vec3 oc = rayOrigin - center; + float b = dot(oc, rayDir); + float c = dot(oc, oc) - radius * radius; + float h = b * b - c; + if (h < 0.0) return vec2(-1.0); + h = sqrt(h); + return vec2(-b - h, -b + h); + } + + void main() { + vec2 uvCenter = (2.0 * gl_FragCoord.xy - uResolution.xy) / uResolution.y; + + vec3 rayOriginWorld = vec3(0.0, 0.0, 0.0); + vec3 rayDirectionWorld = normalize(vec3(uvCenter * uZoom, -1.0)); + + vec3 localRayOrigin = rayOriginWorld - uSphereCenter; + vec3 localRayDirection = rayDirectionWorld; + + vec2 hitOuter = intersectSphere(localRayOrigin, localRayDirection, vec3(0.0), uSphereRadius); + + if (hitOuter.x < 0.0) { + gl_FragColor = vec4(0.0, 0.0, 0.0, 1.0); + return; + } + + float tStart = max(0.0, hitOuter.x); + float tEnd = hitOuter.y; + + float stepSize = (tEnd - tStart) / float(uMaxSteps); + float currentDistance = tStart; + + vec4 accumulatedColor = vec4(0.0); + + for (int i = 0; i < 250; i++) { + if (i >= uMaxSteps) break; + + vec3 localPos = localRayOrigin + localRayDirection * currentDistance; + float distFromCenter = length(localPos); + + float wallMask = smoothstep(uInnerRadius, uInnerRadius + 1.0, distFromCenter) + * smoothstep(uSphereRadius, uSphereRadius - 0.2, distFromCenter); + + if (wallMask > 0.01) { + vec3 currentPos = localPos * uTextureScale; + + currentPos.xy *= rotate2D(uTime * 0.15 * uAutoRotationSpeed); + currentPos.xz *= rotate2D(uTime * 0.10 * uAutoRotationSpeed); + + currentPos = abs(currentPos); + + float timeVal = 2.6 + float(i) * 0.356; + vec3 reflectionAxis = normalize(tan(timeVal + vec3(2.5, 1.0, 0.0))); + currentPos = reflect(-currentPos, reflectionAxis) - vec3(-2.6, -1.0, 0.2); + + float accumulatedScale = 0.1; + + if (currentPos.x < currentPos.z) { + currentPos = currentPos.zyx; + } + + for (int k = 0; k < 16; k++) { + if (k >= uFoldSteps) break; + currentPos *= uKifsScale; + accumulatedScale *= uKifsScale; + currentPos.y += uKifsOffset; + + if (currentPos.y > currentPos.z) currentPos = currentPos.xzy; + + currentPos *= uKifsScale; + accumulatedScale *= uKifsScale; + currentPos.y += uKifsOffset; + + if (currentPos.x < currentPos.y) currentPos = currentPos.yxz; + } + + float currentDensity = max(length(currentPos.xz) / accumulatedScale, 0.001); + + vec3 spatialPhase = currentPos.xyz; + vec3 lightIntensity = exp(sin(uTime * uLightSpeed + spatialPhase)) / currentDensity; + + accumulatedColor.rgb += uBaseColor * lightIntensity * stepSize * uBrightness * wallMask; + } + + currentDistance += stepSize; + } + + gl_FragColor.rgb = tanh(accumulatedColor.rgb); + gl_FragColor.a = 1.0; + + vec3 localHitPoint = localRayOrigin + localRayDirection * hitOuter.x; + vec3 surfaceNormalLocal = normalize(localHitPoint); + float edgeHighlight = 0.7 - max(dot(-localRayDirection, surfaceNormalLocal), 0.5); + gl_FragColor.rgb += uBaseColor * 0.08 * pow(edgeHighlight, 4.0); + } +`; + +export default function AnunakiSphere({ stream, settings }: Props) { + const containerRef = useRef(null); + const animationRef = useRef(null); + const audioCtxRef = useRef(null); + const sourceRef = useRef(null); + const settingsRef = useRef(settings); + + useEffect(() => { + settingsRef.current = settings; + }, [settings]); + + useEffect(() => { + if (!containerRef.current) return; + + const container = containerRef.current; + const w = container.clientWidth; + const h = container.clientHeight; + + const audioCtx = new (window.AudioContext || (window as any).webkitAudioContext)(); + audioCtxRef.current = audioCtx; + + const analyser = audioCtx.createAnalyser(); + analyser.fftSize = 2048; + analyser.smoothingTimeConstant = 0.75; + + const kickAnalyser = audioCtx.createAnalyser(); + kickAnalyser.fftSize = 4096; + kickAnalyser.smoothingTimeConstant = 0.2; + + const source = audioCtx.createMediaStreamSource(stream); + source.connect(analyser); + source.connect(kickAnalyser); + sourceRef.current = source; + + const freqBins = analyser.frequencyBinCount; + const freqData = new Uint8Array(freqBins); + + const kickBins = kickAnalyser.frequencyBinCount; + const kickFreqData = new Uint8Array(kickBins); + const prevKickFreqData = new Float32Array(kickBins); + let lastKickTime = 0; + const fluxHistory: number[] = []; + const KICK_COOLDOWN = 120; + const FLUX_HISTORY_SIZE = 60; + + const DPR = Math.min(window.devicePixelRatio, 1.0) * 0.8; + + const renderer = new THREE.WebGLRenderer({ antialias: false }); + renderer.setPixelRatio(DPR); + renderer.setSize(w, h); + renderer.outputColorSpace = THREE.SRGBColorSpace; + while (container.firstChild) container.removeChild(container.firstChild); + container.appendChild(renderer.domElement); + + const scene = new THREE.Scene(); + const camera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0.1, 10); + camera.position.z = 1; + + const BASE_KIFS_SCALE = 1.26; + const BASE_KIFS_OFFSET = 8.9; + const BASE_TEXTURE_SCALE = 1.45; + const BASE_BRIGHTNESS = 1.423; + const BASE_LIGHT_SPEED = 5.5; + const BASE_AUTO_SPIN = 1.0; + const BASE_COLOR_BOOST = 3.0; + const MAX_STEPS = 148; + const FOLD_STEPS = 6; + const SPHERE_RADIUS = 15.0; + const INNER_RADIUS = 14.0; + const SPHERE_CENTER_Z = -25.0; + + const baseColor = new THREE.Color('#7780ff'); + const baseHSL = { h: 0, s: 0, l: 0 }; + baseColor.getHSL(baseHSL); + + const uniforms = { + uResolution: { value: new THREE.Vector2(w * DPR, h * DPR) }, + uTime: { value: 0.0 }, + uMaxSteps: { value: MAX_STEPS }, + uFoldSteps: { value: FOLD_STEPS }, + uKifsScale: { value: BASE_KIFS_SCALE }, + uKifsOffset: { value: BASE_KIFS_OFFSET }, + uTextureScale: { value: BASE_TEXTURE_SCALE }, + uBrightness: { value: BASE_BRIGHTNESS }, + uSphereRadius: { value: SPHERE_RADIUS }, + uInnerRadius: { value: INNER_RADIUS }, + uSphereCenter: { value: new THREE.Vector3(0.0, 0.0, SPHERE_CENTER_Z) }, + uBaseColor: { value: new THREE.Vector3() }, + uLightSpeed: { value: BASE_LIGHT_SPEED }, + uAutoRotationSpeed: { value: BASE_AUTO_SPIN }, + uZoom: { value: 1.0 }, + }; + + const material = new THREE.ShaderMaterial({ + vertexShader, + fragmentShader, + uniforms, + depthWrite: false, + depthTest: false, + }); + + const quad = new THREE.Mesh(new THREE.PlaneGeometry(2, 2), material); + scene.add(quad); + + const tmpColor = new THREE.Color(); + const clock = new THREE.Clock(); + let smoothedBass = 0; + let smoothedMids = 0; + let smoothedHighs = 0; + + const draw = () => { + animationRef.current = requestAnimationFrame(draw); + const s = settingsRef.current; + const now = performance.now(); + + analyser.getByteFrequencyData(freqData); + const sampleRate = audioCtx.sampleRate; + const binHz = sampleRate / analyser.fftSize; + + const subBassEnd = Math.min(Math.floor(60 / binHz), freqBins); + const bassEnd = Math.min(Math.floor(250 / binHz), freqBins); + const midEnd = Math.min(Math.floor(2000 / binHz), freqBins); + const highMidEnd = Math.min(Math.floor(6000 / binHz), freqBins); + + const bandEnergy = (start: number, end: number) => { + let sum = 0; + for (let i = start; i < end; i++) sum += freqData[i]; + return end > start ? sum / ((end - start) * 255) : 0; + }; + + const subBass = bandEnergy(0, subBassEnd) * s.sensitivity; + const bass = bandEnergy(subBassEnd, bassEnd) * s.sensitivity; + const mids = bandEnergy(bassEnd, midEnd) * s.sensitivity; + const highMids = bandEnergy(midEnd, highMidEnd) * s.sensitivity; + const highs = bandEnergy(highMidEnd, freqBins) * s.sensitivity; + + smoothedBass += ((subBass + bass) * 0.5 - smoothedBass) * 0.2; + smoothedMids += (mids - smoothedMids) * 0.15; + smoothedHighs += ((highMids + highs) * 0.5 - smoothedHighs) * 0.15; + + kickAnalyser.getByteFrequencyData(kickFreqData); + const kickBinHz = sampleRate / kickAnalyser.fftSize; + const kickSubBassStart = Math.max(1, Math.floor(20 / kickBinHz)); + const kickSubBassEnd = Math.min(Math.floor(60 / kickBinHz), kickBins); + const kickSubBassBins = Math.max(1, kickSubBassEnd - kickSubBassStart); + + let flux = 0; + for (let i = kickSubBassStart; i < kickSubBassEnd; i++) { + const diff = kickFreqData[i] - prevKickFreqData[i]; + if (diff > 0) flux += diff; + } + flux /= kickSubBassBins * 255; + + let kickSubBassEnergy = 0; + for (let i = kickSubBassStart; i < kickSubBassEnd; i++) { + kickSubBassEnergy += kickFreqData[i]; + } + kickSubBassEnergy /= kickSubBassBins * 255; + + for (let i = 0; i < kickBins; i++) prevKickFreqData[i] = kickFreqData[i]; + + fluxHistory.push(flux); + if (fluxHistory.length > FLUX_HISTORY_SIZE) fluxHistory.shift(); + const meanFlux = fluxHistory.reduce((a, b) => a + b, 0) / fluxHistory.length; + const stdFlux = Math.sqrt( + fluxHistory.reduce((a, b) => a + (b - meanFlux) ** 2, 0) / fluxHistory.length + ); + const sortedFlux = [...fluxHistory].sort((a, b) => a - b); + const medianFlux = sortedFlux[Math.floor(sortedFlux.length / 2)] || 0; + const kickThreshold = medianFlux + stdFlux * 1.2 + 0.02; + + const SUB_BASS_GATE = 0.10; + const isKick = + flux > kickThreshold && + kickSubBassEnergy > SUB_BASS_GATE && + now - lastKickTime > KICK_COOLDOWN; + if (isKick) lastKickTime = now; + const kickFlash = Math.max(0, 1 - (now - lastKickTime) / 200); + + const delta = clock.getDelta(); + + uniforms.uTime.value += delta * s.speed; + uniforms.uAutoRotationSpeed.value = BASE_AUTO_SPIN * s.speed * (1.0 + smoothedMids * 0.3); + uniforms.uLightSpeed.value = BASE_LIGHT_SPEED * s.speed * (1.0 + smoothedMids * 0.5); + uniforms.uKifsScale.value = BASE_KIFS_SCALE + smoothedBass * 0.04 + kickFlash * 0.03; + uniforms.uTextureScale.value = BASE_TEXTURE_SCALE + smoothedHighs * 0.25; + uniforms.uBrightness.value = BASE_BRIGHTNESS * (1.0 + smoothedBass * 0.6) + kickFlash * 1.2; + + const scaleClamped = Math.max(0.5, Math.min(3.0, s.scale)); + uniforms.uZoom.value = 1.0 / scaleClamped; + + const hueDelta = s.hueShift / 360; + tmpColor.setHSL((baseHSL.h + hueDelta + 1) % 1, baseHSL.s, baseHSL.l); + uniforms.uBaseColor.value.set( + tmpColor.r * BASE_COLOR_BOOST, + tmpColor.g * BASE_COLOR_BOOST, + tmpColor.b * BASE_COLOR_BOOST + ); + + renderer.render(scene, camera); + }; + + draw(); + + const handleResize = () => { + const width = container.clientWidth; + const height = container.clientHeight; + renderer.setSize(width, height); + uniforms.uResolution.value.set(width * DPR, height * DPR); + }; + window.addEventListener('resize', handleResize); + + return () => { + window.removeEventListener('resize', handleResize); + if (animationRef.current) cancelAnimationFrame(animationRef.current); + if (sourceRef.current) sourceRef.current.disconnect(); + if (audioCtxRef.current && audioCtxRef.current.state !== 'closed') { + audioCtxRef.current.close(); + } + quad.geometry.dispose(); + material.dispose(); + renderer.dispose(); + kickAnalyser.disconnect(); + }; + }, [stream]); + + return
; +} diff --git a/src/components/visualizers/Ascii.tsx b/src/components/visualizers/Ascii.tsx new file mode 100644 index 0000000..d757d2d --- /dev/null +++ b/src/components/visualizers/Ascii.tsx @@ -0,0 +1,127 @@ +import { useEffect, useRef } from 'react'; +import { VisualizerSettings } from '../../types'; + +interface Props { + stream: MediaStream; + settings: VisualizerSettings; +} + +const CHARS = ' `.-\':_,^=;><+!rc*/z?sLTv)J7(|Fi{C}fI31tlu[neoZ5Yxjya]2ESwqkP6h9d4VpOGbUAKXHm8RD#$Bg0MNWQ%&@█'; + +export default function Ascii({ stream, settings }: Props) { + const canvasRef = useRef(null); + const containerRef = useRef(null); + const animationRef = useRef(null); + const audioCtxRef = useRef(null); + const analyserRef = useRef(null); + const sourceRef = useRef(null); + const settingsRef = useRef(settings); + + useEffect(() => { + settingsRef.current = settings; + }, [settings]); + + useEffect(() => { + if (!canvasRef.current || !containerRef.current) return; + + const canvas = canvasRef.current; + const ctx = canvas.getContext('2d')!; + + const audioCtx = new (window.AudioContext || (window as any).webkitAudioContext)(); + audioCtxRef.current = audioCtx; + + const analyser = audioCtx.createAnalyser(); + analyser.fftSize = 256; + analyser.smoothingTimeConstant = 0.8; + analyserRef.current = analyser; + + const source = audioCtx.createMediaStreamSource(stream); + source.connect(analyser); + sourceRef.current = source; + + const bufferLength = analyser.frequencyBinCount; + const dataArray = new Uint8Array(bufferLength); + + const history: number[][] = []; + let cols = 80; + let rows = 30; + let fontSize = 10; + let lineHeight = 18; + let charWidth = 8; + + const resize = () => { + if (!containerRef.current) return; + canvas.width = containerRef.current.clientWidth; + canvas.height = containerRef.current.clientHeight; + const targetCols = 80; + charWidth = Math.max(8, Math.floor(canvas.width / targetCols)); + fontSize = Math.floor(charWidth * 1.6); + lineHeight = Math.floor(fontSize * 1.1); + cols = Math.floor(canvas.width / charWidth); + rows = Math.floor(canvas.height / lineHeight); + history.length = 0; + }; + window.addEventListener('resize', resize); + resize(); + + const draw = () => { + animationRef.current = requestAnimationFrame(draw); + const s = settingsRef.current; + + analyser.getByteFrequencyData(dataArray); + + const row: number[] = []; + for (let i = 0; i < cols; i++) { + const idx = Math.floor((i / cols) * bufferLength); + row.push(Math.min(1, (dataArray[idx] / 255) * s.sensitivity)); + } + + history.unshift(row); + const maxRows = Math.floor(rows * s.scale); + if (history.length > maxRows) history.length = maxRows; + + ctx.fillStyle = '#000'; + ctx.fillRect(0, 0, canvas.width, canvas.height); + ctx.font = `${fontSize}px monospace`; + ctx.textBaseline = 'top'; + + const time = Date.now() * 0.001 * s.speed; + + for (let y = 0; y < history.length; y++) { + const rowData = history[y]; + const fade = 1 - (y / history.length) * 0.6; + for (let x = 0; x < cols; x++) { + const val = x < rowData.length ? rowData[x] : 0; + if (val < 0.02) continue; + + const charIdx = Math.floor(val * (CHARS.length - 1)); + const ch = CHARS[charIdx]; + + const hue = ((x / cols) * 120 + s.hueShift + time * 30) % 360; + const lightness = 40 + val * 40; + const alpha = (0.3 + val * 0.7) * fade; + + ctx.fillStyle = `hsla(${hue},85%,${lightness}%,${alpha})`; + ctx.fillText(ch, x * charWidth, y * lineHeight); + } + } + }; + + draw(); + + return () => { + window.removeEventListener('resize', resize); + if (animationRef.current) cancelAnimationFrame(animationRef.current); + if (sourceRef.current) sourceRef.current.disconnect(); + if (audioCtxRef.current && audioCtxRef.current.state !== 'closed') { + audioCtxRef.current.close(); + } + }; + }, [stream]); + + return ( +
+ +
+ ); +} diff --git a/src/components/visualizers/AudioDebug.tsx b/src/components/visualizers/AudioDebug.tsx new file mode 100644 index 0000000..e2afff3 --- /dev/null +++ b/src/components/visualizers/AudioDebug.tsx @@ -0,0 +1,732 @@ +import { useEffect, useRef } from 'react'; +import { VisualizerSettings } from '../../types'; + +interface Props { + stream: MediaStream; + settings: VisualizerSettings; +} + +export default function AudioDebug({ stream, settings }: Props) { + const canvasRef = useRef(null); + const containerRef = useRef(null); + const animationRef = useRef(null); + const audioCtxRef = useRef(null); + const analyserRef = useRef(null); + const sourceRef = useRef(null); + const settingsRef = useRef(settings); + + useEffect(() => { + settingsRef.current = settings; + }, [settings]); + + useEffect(() => { + if (!canvasRef.current || !containerRef.current) return; + + const canvas = canvasRef.current; + const ctx = canvas.getContext('2d'); + if (!ctx) return; + + const audioCtx = new (window.AudioContext || (window as any).webkitAudioContext)(); + audioCtxRef.current = audioCtx; + + // Main analyser for display (smooth for visuals) + const analyser = audioCtx.createAnalyser(); + analyser.fftSize = 2048; + analyser.smoothingTimeConstant = 0.7; + analyserRef.current = analyser; + + // Separate analyser for kick detection (minimal smoothing for transient response) + const kickAnalyser = audioCtx.createAnalyser(); + kickAnalyser.fftSize = 1024; + kickAnalyser.smoothingTimeConstant = 0.2; + + const source = audioCtx.createMediaStreamSource(stream); + source.connect(analyser); + source.connect(kickAnalyser); + sourceRef.current = source; + + // Stereo channel splitting for phase/correlation panel + const splitter = audioCtx.createChannelSplitter(2); + source.connect(splitter); + const analyserL = audioCtx.createAnalyser(); + analyserL.fftSize = 2048; + analyserL.smoothingTimeConstant = 0; + const analyserR = audioCtx.createAnalyser(); + analyserR.fftSize = 2048; + analyserR.smoothingTimeConstant = 0; + splitter.connect(analyserL, 0); + splitter.connect(analyserR, 1); + const stereoN = analyserL.fftSize; + const bufL = new Float32Array(stereoN); + const bufR = new Float32Array(stereoN); + + const bufferLength = analyser.frequencyBinCount; + const freqData = new Uint8Array(bufferLength); + const timeData = new Uint8Array(analyser.fftSize); + + const kickBufferLength = kickAnalyser.frequencyBinCount; + const kickFreqData = new Uint8Array(kickBufferLength); + const prevKickFreqData = new Float32Array(kickBufferLength); + + let kickHistory: number[] = []; + let lastKickTime = 0; + let kickCount = 0; + let kickTimestamps: number[] = []; + let fluxHistory: number[] = []; + const KICK_COOLDOWN = 120; + const HISTORY_SIZE = 90; + const FLUX_HISTORY_SIZE = 60; + + // Spectrogram state + let spectrogramImageData: ImageData | null = null; + let spectrogramW = 0; + let spectrogramH = 0; + + // Stereo correlation state + let smoothedCorrelation = 0; + + const resize = () => { + if (containerRef.current && canvasRef.current) { + canvasRef.current.width = containerRef.current.clientWidth; + canvasRef.current.height = containerRef.current.clientHeight; + } + }; + window.addEventListener('resize', resize); + resize(); + + const draw = () => { + animationRef.current = requestAnimationFrame(draw); + const w = canvas.width; + const h = canvas.height; + const s = settingsRef.current; + const now = performance.now(); + + analyser.getByteFrequencyData(freqData); + analyser.getByteTimeDomainData(timeData); + + ctx.fillStyle = '#0a0a0f'; + ctx.fillRect(0, 0, w, h); + + const sampleRate = audioCtx.sampleRate; + const binHz = sampleRate / analyser.fftSize; + + // Frequency band boundaries (in bins) + const subBassEnd = Math.min(Math.floor(60 / binHz), bufferLength); + const bassEnd = Math.min(Math.floor(250 / binHz), bufferLength); + const midEnd = Math.min(Math.floor(2000 / binHz), bufferLength); + const highMidEnd = Math.min(Math.floor(6000 / binHz), bufferLength); + + // Compute band energies + const bandEnergy = (start: number, end: number) => { + let sum = 0; + for (let i = start; i < end; i++) sum += freqData[i]; + return end > start ? (sum / (end - start)) / 255 : 0; + }; + + const subBass = bandEnergy(0, subBassEnd) * s.sensitivity; + const bass = bandEnergy(subBassEnd, bassEnd) * s.sensitivity; + const mid = bandEnergy(bassEnd, midEnd) * s.sensitivity; + const highMid = bandEnergy(midEnd, highMidEnd) * s.sensitivity; + const high = bandEnergy(highMidEnd, bufferLength) * s.sensitivity; + + // RMS & Peak + let rmsSum = 0; + let peak = 0; + for (let i = 0; i < timeData.length; i++) { + const v = (timeData[i] - 128) / 128; + rmsSum += v * v; + if (Math.abs(v) > peak) peak = Math.abs(v); + } + const rms = Math.sqrt(rmsSum / timeData.length) * s.sensitivity; + peak *= s.sensitivity; + + // Kick detection using spectral flux in bass range + kickAnalyser.getByteFrequencyData(kickFreqData); + const kickSampleRate = audioCtx.sampleRate; + const kickBinHz = kickSampleRate / kickAnalyser.fftSize; + const kickBassEnd = Math.min(Math.floor(150 / kickBinHz), kickBufferLength); + + // Spectral flux: sum of positive differences in bass bins (onset = energy appearing) + let flux = 0; + for (let i = 1; i < kickBassEnd; i++) { + const diff = kickFreqData[i] - prevKickFreqData[i]; + if (diff > 0) flux += diff; + } + flux /= kickBassEnd * 255; + + // Store current frame for next comparison + for (let i = 0; i < kickBufferLength; i++) { + prevKickFreqData[i] = kickFreqData[i]; + } + + fluxHistory.push(flux); + if (fluxHistory.length > FLUX_HISTORY_SIZE) fluxHistory.shift(); + + // Adaptive threshold: median + factor * deviation + const sortedFlux = [...fluxHistory].sort((a, b) => a - b); + const medianFlux = sortedFlux[Math.floor(sortedFlux.length / 2)] || 0; + const meanFlux = fluxHistory.reduce((a, b) => a + b, 0) / fluxHistory.length; + const stdFlux = Math.sqrt(fluxHistory.reduce((a, b) => a + (b - meanFlux) ** 2, 0) / fluxHistory.length); + const kickThreshold = medianFlux + stdFlux * 1.2 + 0.02; + + const isKick = flux > kickThreshold && (now - lastKickTime) > KICK_COOLDOWN; + + if (isKick) { + lastKickTime = now; + kickCount++; + kickTimestamps.push(now); + // Keep last 30 seconds of timestamps + while (kickTimestamps.length > 0 && now - kickTimestamps[0] > 30000) { + kickTimestamps.shift(); + } + } + + kickHistory.push(isKick ? 1 : 0); + if (kickHistory.length > HISTORY_SIZE) kickHistory.shift(); + + // BPM estimation from kick intervals + let bpmEstimate = 0; + if (kickTimestamps.length >= 4) { + const intervals: number[] = []; + for (let i = 1; i < kickTimestamps.length; i++) { + intervals.push(kickTimestamps[i] - kickTimestamps[i - 1]); + } + // Filter out outliers (keep intervals between 300ms and 1000ms = 60-200 BPM range) + const validIntervals = intervals.filter(v => v >= 300 && v <= 1000); + if (validIntervals.length >= 3) { + const avgInterval = validIntervals.reduce((a, b) => a + b, 0) / validIntervals.length; + bpmEstimate = Math.round(60000 / avgInterval); + } + } + + const kickBassEnergy = flux; + const avgBassEnergy = meanFlux; + + const kickFlash = Math.max(0, 1 - (now - lastKickTime) / 200); + + // Layout + const margin = 16; + const panelW = (w - margin * 3) / 2; + const panelH = (h - margin * 5) / 4; + + // Panel backgrounds + const drawPanel = (x: number, y: number, pw: number, ph: number, title: string, flash = false) => { + ctx.fillStyle = flash ? `rgba(255, 50, 50, ${0.1 + kickFlash * 0.15})` : 'rgba(20, 20, 30, 0.8)'; + ctx.fillRect(x, y, pw, ph); + ctx.strokeStyle = flash ? `rgba(255, 80, 80, ${0.4 + kickFlash * 0.6})` : 'rgba(80, 80, 120, 0.5)'; + ctx.lineWidth = 1; + ctx.strokeRect(x, y, pw, ph); + ctx.fillStyle = flash ? '#ff6666' : '#888899'; + ctx.font = '11px monospace'; + ctx.fillText(title, x + 8, y + 14); + }; + + // 1. Frequency Spectrum (top-left) + drawPanel(margin, margin, panelW, panelH, 'FREQUENCY SPECTRUM'); + const specX = margin + 8; + const specY = margin + 24; + const specW = panelW - 16; + const specH = panelH - 32; + + const barCount = Math.min(bufferLength, Math.floor(specW / 3)); + const barW = specW / barCount; + for (let i = 0; i < barCount; i++) { + const idx = Math.floor((i / barCount) * bufferLength); + const val = Math.min(1, (freqData[idx] / 255) * s.sensitivity); + const barH = val * specH; + const freq = idx * binHz; + + let hue: number; + if (freq < 60) hue = 0; + else if (freq < 250) hue = 30; + else if (freq < 2000) hue = 120; + else if (freq < 6000) hue = 200; + else hue = 280; + + hue = (hue + s.hueShift) % 360; + ctx.fillStyle = `hsla(${hue}, 80%, ${40 + val * 30}%, ${0.6 + val * 0.4})`; + ctx.fillRect(specX + i * barW, specY + specH - barH, barW - 1, barH); + } + + // Frequency labels + ctx.fillStyle = '#555566'; + ctx.font = '9px monospace'; + const freqLabels = [60, 250, 2000, 6000, 16000]; + for (const freq of freqLabels) { + const bin = Math.floor(freq / binHz); + const xPos = specX + (bin / bufferLength) * specW; + if (xPos < specX + specW) { + ctx.fillText(freq >= 1000 ? `${freq / 1000}k` : `${freq}`, xPos, specY + specH + 10); + } + } + + // 2. Waveform (top-right) + drawPanel(margin * 2 + panelW, margin, panelW, panelH, 'WAVEFORM'); + const waveX = margin * 2 + panelW + 8; + const waveY = margin + 24; + const waveW = panelW - 16; + const waveH = panelH - 32; + + ctx.strokeStyle = `hsla(${(180 + s.hueShift) % 360}, 80%, 60%, 0.8)`; + ctx.lineWidth = 1.5; + ctx.beginPath(); + const sliceWidth = waveW / timeData.length; + for (let i = 0; i < timeData.length; i++) { + const v = (timeData[i] - 128) / 128; + const y = waveY + waveH / 2 - v * (waveH / 2) * s.sensitivity; + if (i === 0) ctx.moveTo(waveX, y); + else ctx.lineTo(waveX + i * sliceWidth, y); + } + ctx.stroke(); + + // Zero line + ctx.strokeStyle = 'rgba(80, 80, 120, 0.3)'; + ctx.lineWidth = 0.5; + ctx.beginPath(); + ctx.moveTo(waveX, waveY + waveH / 2); + ctx.lineTo(waveX + waveW, waveY + waveH / 2); + ctx.stroke(); + + // 3. Kick Detection (middle-left) + drawPanel(margin, margin * 2 + panelH, panelW, panelH, 'KICK DETECTION', true); + const kickX = margin + 8; + const kickY = margin * 2 + panelH + 24; + const kickW = panelW - 16; + const kickH = panelH - 32; + + // Kick indicator circle + const circleR = Math.min(kickH * 0.3, 40); + const circleX = kickX + circleR + 10; + const circleY = kickY + kickH / 2; + + ctx.beginPath(); + ctx.arc(circleX, circleY, circleR, 0, Math.PI * 2); + ctx.fillStyle = `rgba(255, 50, 50, ${kickFlash * 0.9})`; + ctx.fill(); + ctx.strokeStyle = `rgba(255, 80, 80, ${0.3 + kickFlash * 0.7})`; + ctx.lineWidth = 2; + ctx.stroke(); + + if (kickFlash > 0.5) { + ctx.beginPath(); + ctx.arc(circleX, circleY, circleR + 8, 0, Math.PI * 2); + ctx.strokeStyle = `rgba(255, 50, 50, ${kickFlash * 0.4})`; + ctx.lineWidth = 1; + ctx.stroke(); + } + + ctx.fillStyle = kickFlash > 0.5 ? '#ffffff' : '#ff6666'; + ctx.font = 'bold 14px monospace'; + ctx.textAlign = 'center'; + ctx.fillText('KICK', circleX, circleY + 5); + ctx.textAlign = 'left'; + + // Kick history graph + const histX = kickX + circleR * 2 + 40; + const histW = kickW - circleR * 2 - 50; + const histH = kickH * 0.5; + const histY = kickY + kickH / 2 - histH / 2; + + ctx.strokeStyle = 'rgba(80, 80, 120, 0.3)'; + ctx.lineWidth = 0.5; + ctx.strokeRect(histX, histY, histW, histH); + + // Draw spectral flux history + const maxFlux = Math.max(...fluxHistory, 0.01); + ctx.strokeStyle = `hsla(${(20 + s.hueShift) % 360}, 80%, 50%, 0.6)`; + ctx.lineWidth = 1.5; + ctx.beginPath(); + for (let i = 0; i < fluxHistory.length; i++) { + const x = histX + (i / FLUX_HISTORY_SIZE) * histW; + const y = histY + histH - (fluxHistory[i] / maxFlux) * histH; + if (i === 0) ctx.moveTo(x, y); + else ctx.lineTo(x, y); + } + ctx.stroke(); + + // Draw threshold line + ctx.strokeStyle = 'rgba(255, 80, 80, 0.5)'; + ctx.lineWidth = 1; + ctx.setLineDash([4, 4]); + const threshY = histY + histH - (kickThreshold / maxFlux) * histH; + ctx.beginPath(); + ctx.moveTo(histX, threshY); + ctx.lineTo(histX + histW, threshY); + ctx.stroke(); + ctx.setLineDash([]); + + // Kick markers + for (let i = 0; i < kickHistory.length; i++) { + if (kickHistory[i]) { + const x = histX + (i / HISTORY_SIZE) * histW; + ctx.fillStyle = 'rgba(255, 50, 50, 0.7)'; + ctx.fillRect(x - 1, histY, 2, histH); + } + } + + // Stats + ctx.fillStyle = '#aaaacc'; + ctx.font = '11px monospace'; + const statsX = histX; + ctx.fillText(`Kicks: ${kickCount}`, statsX, histY + histH + 16); + ctx.fillText(`BPM: ~${bpmEstimate}`, statsX + 100, histY + histH + 16); + ctx.fillText(`Threshold: ${kickThreshold.toFixed(3)}`, statsX + 200, histY + histH + 16); + + // 4. Band Energy (middle-right) + drawPanel(margin * 2 + panelW, margin * 2 + panelH, panelW, panelH, 'FREQUENCY BANDS'); + const bandX = margin * 2 + panelW + 8; + const bandY = margin * 2 + panelH + 24; + const bandW = panelW - 16; + const bandH = panelH - 32; + + const bands = [ + { label: 'Sub Bass (20-60Hz)', value: subBass, hue: 0 }, + { label: 'Bass (60-250Hz)', value: bass, hue: 30 }, + { label: 'Mid (250-2kHz)', value: mid, hue: 120 }, + { label: 'High Mid (2k-6kHz)', value: highMid, hue: 200 }, + { label: 'High (6k+)', value: high, hue: 280 }, + ]; + + const barGap = 8; + const bandBarH = (bandH - barGap * (bands.length - 1)) / bands.length; + for (let i = 0; i < bands.length; i++) { + const by = bandY + i * (bandBarH + barGap); + const val = Math.min(1, bands[i].value); + const hue = (bands[i].hue + s.hueShift) % 360; + + // Background + ctx.fillStyle = 'rgba(40, 40, 60, 0.5)'; + ctx.fillRect(bandX + 140, by, bandW - 140, bandBarH); + + // Bar + ctx.fillStyle = `hsla(${hue}, 70%, 50%, ${0.5 + val * 0.5})`; + ctx.fillRect(bandX + 140, by, (bandW - 140) * val, bandBarH); + + // Label + ctx.fillStyle = '#888899'; + ctx.font = '10px monospace'; + ctx.fillText(bands[i].label, bandX, by + bandBarH / 2 + 4); + + // Value + ctx.fillStyle = '#aaaacc'; + ctx.fillText(`${(val * 100).toFixed(0)}%`, bandX + bandW - 30, by + bandBarH / 2 + 4); + } + + // 5. Metrics Panel (bottom-left) + drawPanel(margin, margin * 3 + panelH * 2, panelW, panelH, 'AUDIO METRICS'); + const metX = margin + 8; + const metY = margin * 3 + panelH * 2 + 28; + + ctx.font = '12px monospace'; + const metrics = [ + { label: 'RMS Level', value: `${(rms * 100).toFixed(1)}%`, bar: rms }, + { label: 'Peak Level', value: `${(peak * 100).toFixed(1)}%`, bar: peak }, + { label: 'Sample Rate', value: `${sampleRate} Hz`, bar: -1 }, + { label: 'FFT Size', value: `${analyser.fftSize}`, bar: -1 }, + { label: 'Freq Bins', value: `${bufferLength}`, bar: -1 }, + { label: 'Bin Width', value: `${binHz.toFixed(1)} Hz`, bar: -1 }, + { label: 'Sensitivity', value: `${s.sensitivity.toFixed(1)}x`, bar: -1 }, + ]; + + for (let i = 0; i < metrics.length; i++) { + const my = metY + i * 22; + ctx.fillStyle = '#777788'; + ctx.fillText(metrics[i].label, metX, my); + ctx.fillStyle = '#ccccdd'; + ctx.fillText(metrics[i].value, metX + 130, my); + + if (metrics[i].bar >= 0) { + const mbarX = metX + 220; + const mbarW = panelW - 250; + const mbarH = 10; + ctx.fillStyle = 'rgba(40, 40, 60, 0.5)'; + ctx.fillRect(mbarX, my - 9, mbarW, mbarH); + const val = Math.min(1, metrics[i].bar); + const hue = val > 0.8 ? 0 : val > 0.5 ? 40 : 120; + ctx.fillStyle = `hsla(${(hue + s.hueShift) % 360}, 70%, 50%, 0.8)`; + ctx.fillRect(mbarX, my - 9, mbarW * val, mbarH); + } + } + + // 6. Kick Timeline (bottom-right) + drawPanel(margin * 2 + panelW, margin * 3 + panelH * 2, panelW, panelH, 'ENERGY OVER TIME'); + const tlX = margin * 2 + panelW + 8; + const tlY = margin * 3 + panelH * 2 + 28; + const tlW = panelW - 16; + const tlH = panelH - 40; + + // Draw dynamic energy graph (multiple bands stacked) + const drawEnergyLine = (values: number[], hue: number, label: string, yOffset: number, lineH: number) => { + ctx.strokeStyle = `hsla(${(hue + s.hueShift) % 360}, 70%, 60%, 0.7)`; + ctx.lineWidth = 1; + ctx.beginPath(); + const segW = tlW / values.length; + for (let i = 0; i < values.length; i++) { + const x = tlX + i * segW; + const y = yOffset + lineH - Math.min(1, values[i]) * lineH; + if (i === 0) ctx.moveTo(x, y); + else ctx.lineTo(x, y); + } + ctx.stroke(); + ctx.fillStyle = `hsla(${(hue + s.hueShift) % 360}, 70%, 60%, 0.6)`; + ctx.font = '9px monospace'; + ctx.fillText(label, tlX + 2, yOffset + 10); + }; + + // Use fluxHistory for the energy graph + drawEnergyLine(fluxHistory, 20, 'SPECTRAL FLUX (BASS)', tlY, tlH * 0.45); + + // Current value indicators + ctx.fillStyle = '#666677'; + ctx.font = '10px monospace'; + ctx.fillText(`Flux: ${kickBassEnergy.toFixed(4)}`, tlX + tlW - 140, tlY + 12); + ctx.fillText(`Mean: ${avgBassEnergy.toFixed(4)}`, tlX + tlW - 140, tlY + 24); + + // Overall energy meter at bottom + const meterY = tlY + tlH * 0.55; + const meterH = tlH * 0.4; + ctx.fillStyle = 'rgba(40, 40, 60, 0.3)'; + ctx.fillRect(tlX, meterY, tlW, meterH); + + // Segmented meter + const segments = 40; + const segGap = 2; + const segW2 = (tlW - segGap * (segments - 1)) / segments; + const overallEnergy = Math.min(1, rms * 2); + const litSegments = Math.floor(overallEnergy * segments); + + for (let i = 0; i < segments; i++) { + const sx = tlX + i * (segW2 + segGap); + const lit = i < litSegments; + let hue: number; + if (i < segments * 0.6) hue = 120; + else if (i < segments * 0.8) hue = 60; + else hue = 0; + hue = (hue + s.hueShift) % 360; + ctx.fillStyle = lit ? `hsla(${hue}, 80%, 50%, 0.9)` : 'rgba(40, 40, 60, 0.4)'; + ctx.fillRect(sx, meterY + 4, segW2, meterH - 8); + } + + ctx.fillStyle = '#777788'; + ctx.font = '9px monospace'; + ctx.fillText('OVERALL LEVEL', tlX, meterY + meterH + 12); + ctx.fillText(`${(overallEnergy * 100).toFixed(0)}%`, tlX + tlW - 30, meterY + meterH + 12); + + // 7. Spectrogram (bottom-left) + drawPanel(margin, margin * 4 + panelH * 3, panelW, panelH, 'SPECTROGRAM'); + const sgX = margin + 8; + const sgY = margin * 4 + panelH * 3 + 24; + const sgW = Math.floor(panelW - 16); + const sgH = Math.floor(panelH - 32); + + if (sgW > 0 && sgH > 0) { + if (!spectrogramImageData || spectrogramW !== sgW || spectrogramH !== sgH) { + spectrogramImageData = ctx.createImageData(sgW, sgH); + spectrogramW = sgW; + spectrogramH = sgH; + } + + const data = spectrogramImageData.data; + const rowBytes = sgW * 4; + + // Shift all pixels left by 1 column + for (let row = 0; row < sgH; row++) { + const rowStart = row * rowBytes; + data.copyWithin(rowStart, rowStart + 4, rowStart + rowBytes); + } + + // Write new column on the right edge (log-frequency mapping) + const minFreq = 20; + const maxFreq = sampleRate / 2; + const logMin = Math.log10(minFreq); + const logMax = Math.log10(maxFreq); + + for (let py = 0; py < sgH; py++) { + const t = 1 - py / (sgH - 1); + const logFreq = logMin + t * (logMax - logMin); + const freq = Math.pow(10, logFreq); + const bin = Math.min(Math.round(freq / binHz), bufferLength - 1); + const intensity = Math.min(1, (freqData[bin] / 255) * s.sensitivity); + + let r: number, g: number, b: number; + if (intensity < 0.2) { + const it = intensity / 0.2; + r = Math.floor(it * 40); g = 0; b = Math.floor(it * 80); + } else if (intensity < 0.45) { + const it = (intensity - 0.2) / 0.25; + r = Math.floor(40 + it * 215); g = 0; b = Math.floor(80 - it * 80); + } else if (intensity < 0.7) { + const it = (intensity - 0.45) / 0.25; + r = 255; g = Math.floor(it * 255); b = 0; + } else { + const it = (intensity - 0.7) / 0.3; + r = 255; g = 255; b = Math.floor(it * 255); + } + + const idx = (py * sgW + (sgW - 1)) * 4; + data[idx] = r; + data[idx + 1] = g; + data[idx + 2] = b; + data[idx + 3] = 255; + } + + ctx.putImageData(spectrogramImageData, sgX, sgY); + + // Frequency labels on left edge + ctx.fillStyle = '#555566'; + ctx.font = '9px monospace'; + const sgFreqLabels = [100, 1000, 5000, 10000]; + for (const freq of sgFreqLabels) { + const t2 = (Math.log10(freq) - logMin) / (logMax - logMin); + const ly = sgY + sgH - t2 * sgH; + if (ly > sgY && ly < sgY + sgH - 8) { + ctx.fillText(freq >= 1000 ? `${freq / 1000}k` : `${freq}`, sgX + 2, ly + 3); + } + } + } + + // 8. Stereo Phase / Correlation (bottom-right) + drawPanel(margin * 2 + panelW, margin * 4 + panelH * 3, panelW, panelH, 'STEREO PHASE / CORRELATION'); + const phX = margin * 2 + panelW + 8; + const phY = margin * 4 + panelH * 3 + 24; + const phW = panelW - 16; + const phH = panelH - 32; + + if (phW > 0 && phH > 0) { + analyserL.getFloatTimeDomainData(bufL); + analyserR.getFloatTimeDomainData(bufR); + + // Mono detection + correlation calculation + let sumLR = 0, sumL2 = 0, sumR2 = 0; + let monoDetected = true; + for (let i = 0; i < stereoN; i++) { + sumLR += bufL[i] * bufR[i]; + sumL2 += bufL[i] * bufL[i]; + sumR2 += bufR[i] * bufR[i]; + if (Math.abs(bufR[i] - bufL[i]) > 1e-5) monoDetected = false; + } + const rmsR2 = Math.sqrt(sumR2 / stereoN); + if (rmsR2 < 1e-5) monoDetected = true; + + const denom = Math.sqrt(sumL2 * sumR2); + const correlation = denom > 1e-10 ? sumLR / denom : 0; + smoothedCorrelation += (correlation - smoothedCorrelation) * 0.15; + + // Lissajous XY plot (left ~60%) + const lissSize = Math.min(phW * 0.55, phH - 10); + const lissCx = phX + lissSize / 2 + 5; + const lissCy = phY + phH / 2; + const lissR = lissSize / 2; + + // Crosshair guides + ctx.strokeStyle = 'rgba(80, 80, 120, 0.3)'; + ctx.lineWidth = 0.5; + ctx.beginPath(); + ctx.moveTo(lissCx - lissR, lissCy); + ctx.lineTo(lissCx + lissR, lissCy); + ctx.moveTo(lissCx, lissCy - lissR); + ctx.lineTo(lissCx, lissCy + lissR); + ctx.stroke(); + + // Circular boundary + ctx.beginPath(); + ctx.arc(lissCx, lissCy, lissR, 0, Math.PI * 2); + ctx.strokeStyle = 'rgba(80, 80, 120, 0.2)'; + ctx.stroke(); + + // Plot samples + const step = Math.max(1, Math.floor(stereoN / 512)); + const lissHue = (180 + s.hueShift) % 360; + ctx.fillStyle = `hsla(${lissHue}, 80%, 60%, 0.4)`; + for (let i = 0; i < stereoN; i += step) { + const lx = lissCx + bufL[i] * lissR * s.sensitivity; + const ly = lissCy - bufR[i] * lissR * s.sensitivity; + ctx.fillRect(lx - 0.5, ly - 0.5, 1.5, 1.5); + } + + // Axis labels + ctx.fillStyle = '#555566'; + ctx.font = '9px monospace'; + ctx.fillText('L', lissCx - lissR - 10, lissCy + 3); + ctx.fillText('R', lissCx + lissR + 4, lissCy + 3); + + // Mono indicator + if (monoDetected) { + ctx.fillStyle = 'rgba(255, 200, 50, 0.8)'; + ctx.font = 'bold 12px monospace'; + ctx.textAlign = 'center'; + ctx.fillText('MONO', lissCx, lissCy + lissR + 14); + ctx.textAlign = 'left'; + } + + // Correlation meter (right side) + const meterX2 = phX + lissSize + 30; + const meterW2 = 20; + const meterTop = phY + 5; + const meterBottom = phY + phH - 5; + const meterH2 = meterBottom - meterTop; + const meterMid = meterTop + meterH2 / 2; + + // Background + ctx.fillStyle = 'rgba(40, 40, 60, 0.5)'; + ctx.fillRect(meterX2, meterTop, meterW2, meterH2); + + // Zone coloring + ctx.fillStyle = 'rgba(50, 180, 50, 0.15)'; + ctx.fillRect(meterX2, meterTop, meterW2, meterH2 * 0.25); + ctx.fillStyle = 'rgba(180, 180, 50, 0.10)'; + ctx.fillRect(meterX2, meterTop + meterH2 * 0.25, meterW2, meterH2 * 0.5); + ctx.fillStyle = 'rgba(180, 50, 50, 0.15)'; + ctx.fillRect(meterX2, meterTop + meterH2 * 0.75, meterW2, meterH2 * 0.25); + + // Center line (0 mark) + ctx.strokeStyle = 'rgba(80, 80, 120, 0.5)'; + ctx.lineWidth = 1; + ctx.beginPath(); + ctx.moveTo(meterX2, meterMid); + ctx.lineTo(meterX2 + meterW2, meterMid); + ctx.stroke(); + + // Indicator + const indicatorY = meterMid - smoothedCorrelation * (meterH2 / 2); + const corrHue = smoothedCorrelation > 0.5 ? 120 : smoothedCorrelation > 0 ? 60 : 0; + ctx.fillStyle = `hsla(${(corrHue + s.hueShift) % 360}, 80%, 50%, 0.9)`; + ctx.fillRect(meterX2, Math.min(indicatorY, meterMid), meterW2, Math.abs(indicatorY - meterMid)); + + // Indicator line + ctx.fillStyle = '#ffffff'; + ctx.fillRect(meterX2 - 2, indicatorY - 1, meterW2 + 4, 3); + + // Labels + ctx.fillStyle = '#888899'; + ctx.font = '9px monospace'; + ctx.fillText('+1', meterX2 + meterW2 + 4, meterTop + 4); + ctx.fillText(' 0', meterX2 + meterW2 + 4, meterMid + 3); + ctx.fillText('-1', meterX2 + meterW2 + 4, meterBottom + 3); + + // Numeric readout + ctx.fillStyle = '#aaaacc'; + ctx.font = '11px monospace'; + ctx.fillText(`r = ${smoothedCorrelation.toFixed(2)}`, meterX2 + meterW2 + 4, meterBottom + 18); + } + }; + + draw(); + + return () => { + window.removeEventListener('resize', resize); + if (animationRef.current) cancelAnimationFrame(animationRef.current); + if (sourceRef.current) sourceRef.current.disconnect(); + splitter.disconnect(); + if (audioCtxRef.current && audioCtxRef.current.state !== 'closed') { + audioCtxRef.current.close(); + } + }; + }, [stream]); + + return ( +
+ +
+ ); +} diff --git a/src/components/visualizers/AurumLeaf.tsx b/src/components/visualizers/AurumLeaf.tsx new file mode 100644 index 0000000..a99faf0 --- /dev/null +++ b/src/components/visualizers/AurumLeaf.tsx @@ -0,0 +1,472 @@ +import { useEffect, useRef } from 'react'; +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 { OutputPass } from 'three/examples/jsm/postprocessing/OutputPass.js'; +import { VisualizerSettings } from '../../types'; + +interface Props { + stream: MediaStream; + settings: VisualizerSettings; +} + +const TENTACLE_COUNT = 8; +const CORE_LINES_PER_TENTACLE = 5; +const GLOW_LINES_PER_TENTACLE = 45; +const POINTS_PER_LINE = 150; +const PARTICLE_COUNT = 3100; + +const BASE_CAPSULE_RADIUS = 5.418; +const BASE_CAPSULE_LENGTH = 10.0; +const BASE_CAPSULE_OPACITY = 0.0415; +const BASE_GROUP_SPACING = 4.0; +const BASE_SPREAD = 0.6223; +const BASE_TAPER = 4.248; +const BASE_CURL = 0.2625; +const BASE_WAVE_SPEED = 1.4; +const BASE_WRITHE_AMP = 1.4; +const BASE_PARTICLE_SPEED = 0.735; +const BASE_PARTICLE_ORBIT = 0.33; +const BASE_PARTICLE_SIZE = 0.04802; +const BASE_BLOOM_STRENGTH = 0.264; +const BASE_BLOOM_RADIUS = 0.516; +const BASE_BLOOM_THRESHOLD = 0.2; + +const KICK_BLOOM_PEAK = 1.4; +const KICK_PARTICLE_PEAK = 2.5; +const KICK_BLOOM_TAU = 0.18; +const KICK_PARTICLE_TAU = 0.30; +const KICK_COOLDOWN = 120; +const FLUX_HISTORY_SIZE = 60; + +type TentacleBase = { + baseX: number; + baseZ: number; + phaseX: number; + phaseY: number; + phaseZ: number; +}; + +type StrandData = { + line: THREE.Line; + tentacleIndex: number; + localOffsetX: number; + localOffsetZ: number; + microPhase: number; +}; + +type ParticleData = { + tentacleIndex: number; + t: number; + speed: number; + angle: number; + radiusBase: number; + orbitSpeed: number; +}; + +export default function AurumLeaf({ stream, settings }: Props) { + const containerRef = useRef(null); + const animationRef = useRef(null); + const audioCtxRef = useRef(null); + const sourceRef = useRef(null); + const settingsRef = useRef(settings); + + useEffect(() => { + settingsRef.current = settings; + }, [settings]); + + useEffect(() => { + if (!containerRef.current) return; + const container = containerRef.current; + const w = container.clientWidth; + const h = container.clientHeight; + if (w === 0 || h === 0) return; + + // ---- Audio ---- + const audioCtx = new (window.AudioContext || (window as any).webkitAudioContext)(); + audioCtxRef.current = audioCtx; + + const analyser = audioCtx.createAnalyser(); + analyser.fftSize = 2048; + analyser.smoothingTimeConstant = 0.7; + + const kickAnalyser = audioCtx.createAnalyser(); + kickAnalyser.fftSize = 1024; + kickAnalyser.smoothingTimeConstant = 0.2; + + const source = audioCtx.createMediaStreamSource(stream); + source.connect(analyser); + source.connect(kickAnalyser); + sourceRef.current = source; + + const freqBins = analyser.frequencyBinCount; + const freqData = new Uint8Array(freqBins); + + const kickBins = kickAnalyser.frequencyBinCount; + const kickFreqData = new Uint8Array(kickBins); + const prevKickFreqData = new Float32Array(kickBins); + const fluxHistory: number[] = []; + let lastKickTime = 0; + let bassSmoothed = 0; + let bloomImpulse = 0; + let particleImpulse = 0; + + // ---- Renderer ---- + const DPR = Math.min(window.devicePixelRatio, 1.0) * 0.8; + const renderer = new THREE.WebGLRenderer({ antialias: false }); + renderer.setPixelRatio(DPR); + renderer.setSize(w, h); + renderer.setClearColor(0x000000, 1); + renderer.outputColorSpace = THREE.SRGBColorSpace; + while (container.firstChild) container.removeChild(container.firstChild); + container.appendChild(renderer.domElement); + + // ---- Scene ---- + const scene = new THREE.Scene(); + const camera = new THREE.PerspectiveCamera(60, w / h, 0.1, 100); + camera.position.set(0, 0, 25); + + const ambientLight = new THREE.AmbientLight(0xffffff, 0.8); + scene.add(ambientLight); + + const masterGroup = new THREE.Group(); + scene.add(masterGroup); + + // ---- Post-processing ---- + const renderTarget = new THREE.WebGLRenderTarget(w, h, { + type: THREE.HalfFloatType, + samples: 4, + }); + const composer = new EffectComposer(renderer, renderTarget); + composer.addPass(new RenderPass(scene, camera)); + const bloomPass = new UnrealBloomPass( + new THREE.Vector2(w, h), + BASE_BLOOM_STRENGTH, + BASE_BLOOM_RADIUS, + BASE_BLOOM_THRESHOLD, + ); + composer.addPass(bloomPass); + composer.addPass(new OutputPass()); + + // ---- Colors (base HSL cached for hue shifting) ---- + const baseCoreColor = new THREE.Color('#ffd500'); + const baseGlowColor = new THREE.Color('#ffbf52'); + const coreHSL = { h: 0, s: 0, l: 0 }; + const glowHSL = { h: 0, s: 0, l: 0 }; + baseCoreColor.getHSL(coreHSL); + baseGlowColor.getHSL(glowHSL); + const tmpCore = new THREE.Color(); + const tmpGlow = new THREE.Color(); + + // ---- Materials ---- + const coreLineMaterial = new THREE.LineBasicMaterial({ + color: baseCoreColor.clone(), + transparent: true, + opacity: 0.4, + blending: THREE.AdditiveBlending, + depthWrite: false, + }); + + const glowLineMaterial = new THREE.LineBasicMaterial({ + color: baseGlowColor.clone(), + transparent: true, + opacity: 0.05, + blending: THREE.AdditiveBlending, + depthWrite: false, + }); + + const particleMaterial = new THREE.PointsMaterial({ + color: baseCoreColor.clone(), + size: BASE_PARTICLE_SIZE, + transparent: true, + opacity: 0.9, + blending: THREE.AdditiveBlending, + depthWrite: false, + }); + + // ---- Capsule ---- + const capsuleGeometry = new THREE.CapsuleGeometry( + BASE_CAPSULE_RADIUS, + BASE_CAPSULE_LENGTH, + 16, + 32, + ); + const capsuleMaterial = new THREE.MeshPhysicalMaterial({ + color: baseGlowColor.clone(), + transparent: true, + opacity: BASE_CAPSULE_OPACITY, + roughness: 0.15, + metalness: 1.0, + clearcoat: 1.0, + clearcoatRoughness: 0.15, + specularIntensity: 0.0, + envMapIntensity: 0.0, + blending: THREE.AdditiveBlending, + depthWrite: false, + side: THREE.DoubleSide, + }); + const capsuleMesh = new THREE.Mesh(capsuleGeometry, capsuleMaterial); + masterGroup.add(capsuleMesh); + + // ---- Tentacles ---- + const tentaclesBaseData: TentacleBase[] = []; + const linesData: StrandData[] = []; + const linesGroup = new THREE.Group(); + masterGroup.add(linesGroup); + + const createStrand = (tentacleIndex: number, spread: number, material: THREE.LineBasicMaterial) => { + const geometry = new THREE.BufferGeometry(); + const positions = new Float32Array(POINTS_PER_LINE * 3); + geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3)); + const line = new THREE.Line(geometry, material); + linesGroup.add(line); + linesData.push({ + line, + tentacleIndex, + localOffsetX: (Math.random() - 0.5) * spread, + localOffsetZ: (Math.random() - 0.5) * spread, + microPhase: Math.random() * 0.5, + }); + }; + + for (let r = 0; r < TENTACLE_COUNT; r++) { + tentaclesBaseData.push({ + baseX: (Math.random() - 0.5) * BASE_GROUP_SPACING, + baseZ: (Math.random() - 0.5) * BASE_GROUP_SPACING, + phaseX: Math.random() * Math.PI * 2, + phaseY: Math.random() * Math.PI * 2, + phaseZ: Math.random() * Math.PI * 2, + }); + for (let c = 0; c < CORE_LINES_PER_TENTACLE; c++) createStrand(r, 0.2, coreLineMaterial); + for (let l = 0; l < GLOW_LINES_PER_TENTACLE; l++) createStrand(r, BASE_SPREAD, glowLineMaterial); + } + + // ---- Particles ---- + const particlesData: ParticleData[] = []; + const particleGeometry = new THREE.BufferGeometry(); + const particlePositions = new Float32Array(PARTICLE_COUNT * 3); + for (let i = 0; i < PARTICLE_COUNT; i++) { + particlesData.push({ + tentacleIndex: Math.floor(Math.random() * TENTACLE_COUNT), + t: Math.random(), + speed: (Math.random() * 0.8 + 0.2) * 0.002, + angle: Math.random() * Math.PI * 2, + radiusBase: Math.random(), + orbitSpeed: (Math.random() - 0.5) * 2.0, + }); + } + particleGeometry.setAttribute('position', new THREE.BufferAttribute(particlePositions, 3)); + const particles = new THREE.Points(particleGeometry, particleMaterial); + masterGroup.add(particles); + + // ---- Tentacle position helper ---- + const totalHeight = BASE_CAPSULE_LENGTH + BASE_CAPSULE_RADIUS * 2; + const startY = -totalHeight / 2 + 0.5; + const endY = totalHeight / 2 - 0.5; + const lineLength = endY - startY; + + const getTentaclePosition = ( + tentacleIndex: number, + t: number, + time: number, + microPhase: number, + writheAmp: number, + waveSpeed: number, + out: { x: number; y: number; z: number }, + ) => { + const base = tentaclesBaseData[tentacleIndex]; + const y = startY + t * lineLength; + const activePhase = microPhase * Math.min(t * 5.0, 1.0); + const timePhase = (time + activePhase) * waveSpeed; + const curl = y * BASE_CURL; + const yEnvelope = Math.sin(t * Math.PI); + + const xOffset = + Math.sin(curl + timePhase + base.phaseX) * writheAmp + + Math.sin(curl * 0.5 - timePhase * 0.7 + base.phaseY) * (writheAmp * 0.5); + const zOffset = + Math.cos(curl * 0.8 + timePhase * 1.1 + base.phaseZ) * writheAmp + + Math.cos(curl * 0.4 - timePhase * 0.5 + base.phaseX) * (writheAmp * 0.5); + + out.x = (base.baseX + xOffset) * yEnvelope; + out.y = y; + out.z = (base.baseZ + zOffset) * yEnvelope; + }; + + // ---- Animation loop ---- + const clock = new THREE.Clock(); + let elapsed = 0; + const posOut = { x: 0, y: 0, z: 0 }; + + const draw = () => { + animationRef.current = requestAnimationFrame(draw); + const s = settingsRef.current; + const now = performance.now(); + const dt = Math.min(clock.getDelta(), 0.1); + elapsed += dt; + + // --- Audio: bass band (60-250Hz) --- + analyser.getByteFrequencyData(freqData); + const sampleRate = audioCtx.sampleRate; + const binHz = sampleRate / analyser.fftSize; + const bassLo = Math.max(1, Math.floor(60 / binHz)); + const bassHi = Math.min(Math.ceil(250 / binHz), freqBins); + let bassSum = 0; + for (let i = bassLo; i < bassHi; i++) bassSum += freqData[i]; + const bassRaw = bassHi > bassLo ? bassSum / ((bassHi - bassLo) * 255) : 0; + bassSmoothed += (bassRaw - bassSmoothed) * 0.15; + + // --- Audio: spectral-flux kick detection over bass range --- + kickAnalyser.getByteFrequencyData(kickFreqData); + const kickBinHz = sampleRate / kickAnalyser.fftSize; + const kickLo = Math.max(1, Math.floor(60 / kickBinHz)); + const kickHi = Math.min(Math.ceil(250 / kickBinHz), kickBins); + const kickRange = Math.max(1, kickHi - kickLo); + let flux = 0; + for (let i = kickLo; i < kickHi; i++) { + const diff = kickFreqData[i] - prevKickFreqData[i]; + if (diff > 0) flux += diff; + } + flux /= kickRange * 255; + for (let i = 0; i < kickBins; i++) prevKickFreqData[i] = kickFreqData[i]; + + fluxHistory.push(flux); + if (fluxHistory.length > FLUX_HISTORY_SIZE) fluxHistory.shift(); + const meanFlux = fluxHistory.reduce((a, b) => a + b, 0) / fluxHistory.length; + const stdFlux = Math.sqrt( + fluxHistory.reduce((a, b) => a + (b - meanFlux) ** 2, 0) / fluxHistory.length, + ); + const sortedFlux = [...fluxHistory].sort((a, b) => a - b); + const medianFlux = sortedFlux[Math.floor(sortedFlux.length / 2)] || 0; + const kickThreshold = medianFlux + stdFlux * 1.2 + 0.02; + + const isKick = flux > kickThreshold && now - lastKickTime > KICK_COOLDOWN; + if (isKick) { + lastKickTime = now; + bloomImpulse = KICK_BLOOM_PEAK - BASE_BLOOM_STRENGTH; + particleImpulse = KICK_PARTICLE_PEAK - BASE_PARTICLE_SPEED; + } + bloomImpulse *= Math.exp(-dt / KICK_BLOOM_TAU); + particleImpulse *= Math.exp(-dt / KICK_PARTICLE_TAU); + + // --- Effective parameters --- + const effectiveWriteAmp = BASE_WRITHE_AMP + bassSmoothed * s.sensitivity * 3.5; + const effectiveWaveSpeed = BASE_WAVE_SPEED * s.speed; + const effectiveParticleSpeed = + (BASE_PARTICLE_SPEED + particleImpulse * s.sensitivity) * s.speed; + bloomPass.strength = BASE_BLOOM_STRENGTH + bloomImpulse * s.sensitivity; + + // --- Hue shift (reset-and-rotate to avoid offsetHSL compounding) --- + const hueDelta = s.hueShift / 360; + tmpCore.setHSL(((coreHSL.h + hueDelta) % 1 + 1) % 1, coreHSL.s, coreHSL.l); + tmpGlow.setHSL(((glowHSL.h + hueDelta) % 1 + 1) % 1, glowHSL.s, glowHSL.l); + coreLineMaterial.color.copy(tmpCore); + particleMaterial.color.copy(tmpCore); + glowLineMaterial.color.copy(tmpGlow); + capsuleMaterial.color.copy(tmpGlow); + + // --- Scale & auto-rotate --- + masterGroup.scale.setScalar(s.scale); + masterGroup.rotation.y += dt * 0.14; + + // --- Update lines --- + for (let d = 0; d < linesData.length; d++) { + const data = linesData[d]; + const positions = (data.line.geometry.attributes.position as THREE.BufferAttribute).array as Float32Array; + for (let i = 0; i < POINTS_PER_LINE; i++) { + const t = i / (POINTS_PER_LINE - 1); + getTentaclePosition( + data.tentacleIndex, + t, + elapsed, + data.microPhase, + effectiveWriteAmp, + effectiveWaveSpeed, + posOut, + ); + const bottomTaper = Math.min(Math.pow(t * BASE_TAPER, 2.0), 1.0); + const topTaper = Math.min(Math.pow((1.0 - t) * BASE_TAPER, 2.0), 1.0); + const groupTaper = bottomTaper * topTaper; + positions[i * 3] = posOut.x + data.localOffsetX * groupTaper; + positions[i * 3 + 1] = posOut.y; + positions[i * 3 + 2] = posOut.z + data.localOffsetZ * groupTaper; + } + data.line.geometry.attributes.position.needsUpdate = true; + } + + // --- Update particles --- + for (let i = 0; i < PARTICLE_COUNT; i++) { + const p = particlesData[i]; + p.t -= p.speed * effectiveParticleSpeed; + if (p.t < 0.0) { + p.t = 1.0; + p.tentacleIndex = Math.floor(Math.random() * TENTACLE_COUNT); + } + getTentaclePosition( + p.tentacleIndex, + p.t, + elapsed, + 0, + effectiveWriteAmp, + effectiveWaveSpeed, + posOut, + ); + p.angle += p.orbitSpeed * 0.02; + const bottomTaper = Math.min(Math.pow(p.t * BASE_TAPER, 2.0), 1.0); + const topTaper = Math.min(Math.pow((1.0 - p.t) * BASE_TAPER, 2.0), 1.0); + const groupTaper = bottomTaper * topTaper; + const yEnvelope = Math.sin(p.t * Math.PI); + const currentRadius = p.radiusBase * BASE_PARTICLE_ORBIT * yEnvelope * groupTaper; + particlePositions[i * 3] = posOut.x + Math.sin(p.angle) * currentRadius; + particlePositions[i * 3 + 1] = posOut.y; + particlePositions[i * 3 + 2] = posOut.z + Math.cos(p.angle) * currentRadius; + } + particleGeometry.attributes.position.needsUpdate = true; + + composer.render(); + }; + draw(); + + // ---- Resize ---- + const handleResize = () => { + const width = container.clientWidth; + const height = container.clientHeight; + if (width === 0 || height === 0) return; + camera.aspect = width / height; + camera.updateProjectionMatrix(); + renderer.setSize(width, height); + composer.setSize(width, height); + bloomPass.setSize(width, height); + }; + window.addEventListener('resize', handleResize); + + // ---- Cleanup ---- + return () => { + if (animationRef.current) cancelAnimationFrame(animationRef.current); + window.removeEventListener('resize', handleResize); + if (sourceRef.current) sourceRef.current.disconnect(); + kickAnalyser.disconnect(); + if (audioCtxRef.current && audioCtxRef.current.state !== 'closed') { + audioCtxRef.current.close(); + } + capsuleGeometry.dispose(); + capsuleMaterial.dispose(); + for (const data of linesData) data.line.geometry.dispose(); + coreLineMaterial.dispose(); + glowLineMaterial.dispose(); + particleGeometry.dispose(); + particleMaterial.dispose(); + bloomPass.dispose(); + composer.dispose(); + renderTarget.dispose(); + renderer.dispose(); + renderer.forceContextLoss(); + if (renderer.domElement.parentNode === container) { + container.removeChild(renderer.domElement); + } + }; + }, [stream]); + + return
; +} diff --git a/src/components/visualizers/CyberCity.tsx b/src/components/visualizers/CyberCity.tsx new file mode 100644 index 0000000..ce7da79 --- /dev/null +++ b/src/components/visualizers/CyberCity.tsx @@ -0,0 +1,411 @@ +import { useEffect, useRef } from 'react'; +import * as THREE from 'three'; +import { VisualizerSettings } from '../../types'; + +interface Props { + stream: MediaStream; + settings: VisualizerSettings; +} + +const vertexShader = ` + void main() { + gl_Position = vec4(position, 1.0); + } +`; + +const fragmentShader = ` + uniform float u_time; + uniform vec2 u_resolution; + uniform float u_flightSpeed; + uniform float u_camPosY; + uniform float u_camPitch; + uniform vec3 u_dotColor; + uniform vec3 u_scanColor; + uniform vec3 u_buildingColor; + uniform float u_dotDensity; + uniform float u_fogDensity; + uniform float u_scanSpeed; + uniform float u_scanPulse; + + float hash(vec2 p) { + p = fract(p * vec2(123.34, 456.21)); + p += dot(p, p + 45.32); + return fract(p.x * p.y); + } + + float map(vec3 p) { + vec2 id = floor(p.xz); + vec2 f = fract(p.xz) - 0.5; + + float d = p.y; + + for(int j = -1; j <= 1; j++) { + for(int i = -1; i <= 1; i++) { + vec2 offset = vec2(float(i), float(j)); + vec2 nId = id + offset; + vec2 nF = f - offset; + + float height = hash(nId) * 4.0 + 1.0; + + vec3 q = vec3(nF.x, p.y - height * 0.5, nF.y); + vec3 boxSize = vec3(0.35, height * 0.5, 0.35); + + vec3 distVec = abs(q) - boxSize; + float boxDist = length(max(distVec, 0.0)) + min(max(distVec.x, max(distVec.y, distVec.z)), 0.0); + + d = min(d, boxDist); + } + } + + return d; + } + + vec3 getNormal(vec3 p) { + vec2 e = vec2(0.005, 0.0); + return normalize(vec3( + map(p + e.xyy) - map(p - e.xyy), + map(p + e.yxy) - map(p - e.yxy), + map(p + e.yyx) - map(p - e.yyx) + )); + } + + void main() { + vec2 uv = (gl_FragCoord.xy - 0.5 * u_resolution.xy) / u_resolution.y; + + vec3 rayOrigin = vec3(0.0, u_camPosY, u_time * u_flightSpeed); + vec3 rayDir = normalize(vec3(uv.x, uv.y - u_camPitch, 1.0)); + + float distance = 0.0; + vec3 p; + + for (int i = 0; i < 150; i++) { + p = rayOrigin + rayDir * distance; + float d = map(p); + + if (abs(d) < 0.001 || distance > 100.0) break; + distance += d * 0.75; + } + + vec3 fogColor = u_dotColor * 0.7; + vec3 color = fogColor - uv.y * 0.2; + + float cycle = mod(u_time * u_scanSpeed, 6.0); + float scanY = (cycle < 4.0) ? (6.0 - cycle * 1.75) : -100.0; + float scanEnvelope = smoothstep(0.0, 0.5, cycle) * smoothstep(4.0, 3.5, cycle); + scanEnvelope *= u_scanPulse; + + if (distance < 100.0) { + for(int j = 0; j < 3; j++) { + distance += map(rayOrigin + rayDir * distance); + } + p = rayOrigin + rayDir * distance; + + vec3 normal = getNormal(p); + vec3 absN = abs(normal); + + vec2 surfaceUV; + if (absN.y > 0.5) { + surfaceUV = p.xz; + } else if (absN.z > 0.5) { + surfaceUV = p.xy; + } else { + surfaceUV = p.zy; + } + + float dotDensity = u_dotDensity; + vec2 scaledUV = surfaceUV * dotDensity; + vec2 gridUV = fract(scaledUV) - 0.5; + + float pixelSizeWorld = distance / u_resolution.y; + float viewAngle = max(abs(dot(normal, rayDir)), 0.05); + float pixelSizeUV = pixelSizeWorld / viewAngle; + float pixelSizeTex = pixelSizeUV * dotDensity; + + float radius = 0.2; + float distFromCenter = length(gridUV); + float blur = max(pixelSizeTex * 0.8, 0.01); + + float dotPattern = smoothstep(radius + blur, radius - blur, distFromCenter); + + float fadeToAverage = smoothstep(0.15, 0.6, pixelSizeTex); + dotPattern = mix(dotPattern, 0.125, fadeToAverage); + + float moireFade = smoothstep(25.0, 50.0, distance); + dotPattern = mix(dotPattern, 0.125, moireFade); + + float distToScanner = abs(p.y - scanY); + float scanCore = smoothstep(0.1, 0.0, distToScanner); + float scanGlow = smoothstep(1.5, 0.0, distToScanner); + + vec3 dotBaseColor = u_dotColor * dotPattern; + vec3 materialColor = u_buildingColor + dotBaseColor; + + materialColor += u_scanColor * scanCore * 4.0 * scanEnvelope; + materialColor += u_scanColor * scanGlow * dotPattern * 2.5 * scanEnvelope; + + vec3 lightDir = normalize(vec3(0.8, 0.6, -0.4)); + float diff = max(dot(normal, lightDir), 0.0); + + color = materialColor * diff + (materialColor * 0.3); + color = mix(color, fogColor, 1.0 - exp(-u_fogDensity * distance)); + } + + float tPlane = (scanY - rayOrigin.y) / rayDir.y; + + if (tPlane > 0.0 && tPlane < min(distance, 100.0)) { + vec3 planeHit = rayOrigin + rayDir * tPlane; + + float gridX = smoothstep(0.8, 1.0, fract(planeHit.x * 2.0)); + float gridZ = smoothstep(0.8, 1.0, fract(planeHit.z * 2.0)); + float planeGrid = max(gridX, gridZ); + + vec3 planeLayerColor = u_scanColor * (0.1 + planeGrid * 0.9); + float planeAlpha = smoothstep(100.0, 0.0, tPlane) * 0.5 * scanEnvelope; + + color += planeLayerColor * planeAlpha; + } + + color *= 1.0 - dot(uv, uv) * 0.5; + + gl_FragColor = vec4(color, 1.0); + } +`; + +export default function CyberCity({ stream, settings }: Props) { + const containerRef = useRef(null); + const animationRef = useRef(null); + const audioCtxRef = useRef(null); + const sourceRef = useRef(null); + const settingsRef = useRef(settings); + + useEffect(() => { + settingsRef.current = settings; + }, [settings]); + + useEffect(() => { + if (!containerRef.current) return; + + const container = containerRef.current; + const w = container.clientWidth; + const h = container.clientHeight; + + const audioCtx = new (window.AudioContext || (window as any).webkitAudioContext)(); + audioCtxRef.current = audioCtx; + + const analyser = audioCtx.createAnalyser(); + analyser.fftSize = 2048; + analyser.smoothingTimeConstant = 0.75; + + // fftSize 4096 gives ~10.7 Hz/bin so the sub-bass band (20-60Hz) has enough resolution. + const kickAnalyser = audioCtx.createAnalyser(); + kickAnalyser.fftSize = 4096; + kickAnalyser.smoothingTimeConstant = 0.2; + + const source = audioCtx.createMediaStreamSource(stream); + source.connect(analyser); + source.connect(kickAnalyser); + sourceRef.current = source; + + const freqBins = analyser.frequencyBinCount; + const freqData = new Uint8Array(freqBins); + + const kickBins = kickAnalyser.frequencyBinCount; + const kickFreqData = new Uint8Array(kickBins); + const prevKickFreqData = new Float32Array(kickBins); + let lastKickTime = 0; + const fluxHistory: number[] = []; + const KICK_COOLDOWN = 120; + const FLUX_HISTORY_SIZE = 60; + + const DPR = Math.min(window.devicePixelRatio, 1.0) * 0.8; + + const renderer = new THREE.WebGLRenderer({ antialias: false }); + renderer.setPixelRatio(DPR); + renderer.setSize(w, h); + renderer.outputColorSpace = THREE.SRGBColorSpace; + while (container.firstChild) container.removeChild(container.firstChild); + container.appendChild(renderer.domElement); + + const scene = new THREE.Scene(); + const camera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0.1, 10); + camera.position.z = 1; + + const baseDot = new THREE.Color('#436e84'); + const baseScan = new THREE.Color('#00ccff'); + const baseBuilding = new THREE.Color('#1b202d'); + const dotHSL = { h: 0, s: 0, l: 0 }; + const scanHSL = { h: 0, s: 0, l: 0 }; + baseDot.getHSL(dotHSL); + baseScan.getHSL(scanHSL); + + const BASE_FLIGHT = 0.5; + const BASE_FOG = 0.03; + const BASE_SCAN_SPEED = 0.7; + const BASE_DOT_DENSITY = 12.0; + const BASE_CAM_Y = 10.0; + const BASE_CAM_PITCH = 0.6; + + const uniforms = { + u_time: { value: 0.0 }, + u_resolution: { value: new THREE.Vector2(w * DPR, h * DPR) }, + u_flightSpeed: { value: BASE_FLIGHT }, + u_camPosY: { value: BASE_CAM_Y }, + u_camPitch: { value: BASE_CAM_PITCH }, + u_dotColor: { value: baseDot.clone() }, + u_scanColor: { value: baseScan.clone() }, + u_buildingColor: { value: baseBuilding.clone() }, + u_dotDensity: { value: BASE_DOT_DENSITY }, + u_fogDensity: { value: BASE_FOG }, + u_scanSpeed: { value: BASE_SCAN_SPEED }, + u_scanPulse: { value: 1.0 }, + }; + + const material = new THREE.ShaderMaterial({ + vertexShader, + fragmentShader, + uniforms, + }); + + const quad = new THREE.Mesh(new THREE.PlaneGeometry(2, 2), material); + scene.add(quad); + + const tmpDot = new THREE.Color(); + const tmpScan = new THREE.Color(); + + const clock = new THREE.Clock(); + let smoothedBass = 0; + let smoothedMids = 0; + let smoothedHighs = 0; + + const draw = () => { + animationRef.current = requestAnimationFrame(draw); + const s = settingsRef.current; + const now = performance.now(); + + analyser.getByteFrequencyData(freqData); + const sampleRate = audioCtx.sampleRate; + const binHz = sampleRate / analyser.fftSize; + + const subBassEnd = Math.min(Math.floor(60 / binHz), freqBins); + const bassEnd = Math.min(Math.floor(250 / binHz), freqBins); + const midEnd = Math.min(Math.floor(2000 / binHz), freqBins); + const highMidEnd = Math.min(Math.floor(6000 / binHz), freqBins); + + const bandEnergy = (start: number, end: number) => { + let sum = 0; + for (let i = start; i < end; i++) sum += freqData[i]; + return end > start ? sum / ((end - start) * 255) : 0; + }; + + const subBass = bandEnergy(0, subBassEnd) * s.sensitivity; + const bass = bandEnergy(subBassEnd, bassEnd) * s.sensitivity; + const mids = bandEnergy(bassEnd, midEnd) * s.sensitivity; + const highMids = bandEnergy(midEnd, highMidEnd) * s.sensitivity; + const highs = bandEnergy(highMidEnd, freqBins) * s.sensitivity; + + smoothedBass += ((subBass + bass) * 0.5 - smoothedBass) * 0.2; + smoothedMids += (mids - smoothedMids) * 0.15; + smoothedHighs += ((highMids + highs) * 0.5 - smoothedHighs) * 0.15; + + // Kick detection via spectral flux, restricted to sub-bass (20-60Hz). + kickAnalyser.getByteFrequencyData(kickFreqData); + const kickBinHz = sampleRate / kickAnalyser.fftSize; + const kickSubBassStart = Math.max(1, Math.floor(20 / kickBinHz)); + const kickSubBassEnd = Math.min(Math.floor(60 / kickBinHz), kickBins); + const kickSubBassBins = Math.max(1, kickSubBassEnd - kickSubBassStart); + + let flux = 0; + for (let i = kickSubBassStart; i < kickSubBassEnd; i++) { + const diff = kickFreqData[i] - prevKickFreqData[i]; + if (diff > 0) flux += diff; + } + flux /= kickSubBassBins * 255; + + // Instantaneous sub-bass energy gates flux-only false positives. + let kickSubBassEnergy = 0; + for (let i = kickSubBassStart; i < kickSubBassEnd; i++) { + kickSubBassEnergy += kickFreqData[i]; + } + kickSubBassEnergy /= kickSubBassBins * 255; + + for (let i = 0; i < kickBins; i++) prevKickFreqData[i] = kickFreqData[i]; + + fluxHistory.push(flux); + if (fluxHistory.length > FLUX_HISTORY_SIZE) fluxHistory.shift(); + const meanFlux = fluxHistory.reduce((a, b) => a + b, 0) / fluxHistory.length; + const stdFlux = Math.sqrt( + fluxHistory.reduce((a, b) => a + (b - meanFlux) ** 2, 0) / fluxHistory.length + ); + const sortedFlux = [...fluxHistory].sort((a, b) => a - b); + const medianFlux = sortedFlux[Math.floor(sortedFlux.length / 2)] || 0; + const kickThreshold = medianFlux + stdFlux * 1.2 + 0.02; + + const SUB_BASS_GATE = 0.10; + const isKick = + flux > kickThreshold && + kickSubBassEnergy > SUB_BASS_GATE && + now - lastKickTime > KICK_COOLDOWN; + if (isKick) lastKickTime = now; + const kickFlash = Math.max(0, 1 - (now - lastKickTime) / 200); + + const delta = clock.getDelta(); + + uniforms.u_time.value += delta * s.speed; + uniforms.u_flightSpeed.value = BASE_FLIGHT * s.speed; + + // Scanner cycles faster with mids + uniforms.u_scanSpeed.value = BASE_SCAN_SPEED * s.speed * (1.0 + smoothedMids * 1.4); + + // Scanner intensity pulses on kicks for an audio-reactive sweep + uniforms.u_scanPulse.value = 0.85 + kickFlash * 1.4 + smoothedHighs * 0.3; + + // Fog thins with bass to reveal more of the skyline + uniforms.u_fogDensity.value = Math.max(0.005, BASE_FOG - smoothedBass * 0.018); + + // Dot density tightens with highs (more detail in busy mixes) + uniforms.u_dotDensity.value = BASE_DOT_DENSITY + smoothedHighs * 6.0; + + // Subtle camera dip on kicks for impact + uniforms.u_camPosY.value = BASE_CAM_Y - kickFlash * 1.5; + uniforms.u_camPitch.value = BASE_CAM_PITCH + smoothedMids * 0.05; + + // Scale: a smaller scale lifts the camera higher; larger drops in close + const scaleZoom = 1.0 / Math.max(0.5, Math.min(3.0, s.scale)); + uniforms.u_camPosY.value *= scaleZoom; + + // Hue shift the theme colors + const hueDelta = s.hueShift / 360; + tmpDot.setHSL((dotHSL.h + hueDelta + 1) % 1, dotHSL.s, dotHSL.l); + tmpScan.setHSL((scanHSL.h + hueDelta + 1) % 1, scanHSL.s, scanHSL.l); + uniforms.u_dotColor.value.copy(tmpDot); + uniforms.u_scanColor.value.copy(tmpScan); + + renderer.render(scene, camera); + }; + + draw(); + + const handleResize = () => { + const width = container.clientWidth; + const height = container.clientHeight; + renderer.setSize(width, height); + uniforms.u_resolution.value.set(width * DPR, height * DPR); + }; + window.addEventListener('resize', handleResize); + + return () => { + window.removeEventListener('resize', handleResize); + if (animationRef.current) cancelAnimationFrame(animationRef.current); + if (sourceRef.current) sourceRef.current.disconnect(); + if (audioCtxRef.current && audioCtxRef.current.state !== 'closed') { + audioCtxRef.current.close(); + } + quad.geometry.dispose(); + material.dispose(); + renderer.dispose(); + kickAnalyser.disconnect(); + }; + }, [stream]); + + return
; +} diff --git a/src/components/visualizers/FractalOrb.tsx b/src/components/visualizers/FractalOrb.tsx index 8810900..e53894f 100644 --- a/src/components/visualizers/FractalOrb.tsx +++ b/src/components/visualizers/FractalOrb.tsx @@ -55,7 +55,7 @@ const fragmentShader = ` float b = 0.3 * uAsymmetry; mat2 rotAsym2 = mat2(cos(b), sin(b), -sin(b), cos(b)); - for (int step = 0; step < 12; ++step) { + for (int step = 0; step < 8; ++step) { if (float(step) >= uFractalIters) break; pos.xy *= rotAnim; @@ -98,7 +98,7 @@ const fragmentShader = ` vec3 finalEnergy = vec3(0.0); float fieldVal = 0.0; - for (int i = 0; i < 64; i++) { + for (int i = 0; i < 48; i++) { currentDepth += marchStep * exp(-2.0 * fieldVal); if (currentDepth > limits.y) break; @@ -227,18 +227,39 @@ export default function FractalOrb({ stream, settings }: Props) { // Audio const audioCtx = new (window.AudioContext || (window as any).webkitAudioContext)(); audioCtxRef.current = audioCtx; + + // Main analyser for smooth visual response const analyser = audioCtx.createAnalyser(); - analyser.fftSize = 512; - analyser.smoothingTimeConstant = 0.8; + analyser.fftSize = 2048; + analyser.smoothingTimeConstant = 0.7; + + // Separate analyser for kick detection (minimal smoothing for transient response) + const kickAnalyser = audioCtx.createAnalyser(); + kickAnalyser.fftSize = 1024; + kickAnalyser.smoothingTimeConstant = 0.2; + const source = audioCtx.createMediaStreamSource(stream); source.connect(analyser); + source.connect(kickAnalyser); sourceRef.current = source; - const dataArray = new Uint8Array(analyser.frequencyBinCount); + + const bufferLength = analyser.frequencyBinCount; + const dataArray = new Uint8Array(bufferLength); + + // Kick detection state + const kickBufferLength = kickAnalyser.frequencyBinCount; + const kickFreqData = new Uint8Array(kickBufferLength); + const prevKickFreqData = new Float32Array(kickBufferLength); + let lastKickTime = 0; + let fluxHistory: number[] = []; + let kickEnergy = 0; + const KICK_COOLDOWN = 120; + const FLUX_HISTORY_SIZE = 60; // Renderer - const renderer = new THREE.WebGLRenderer({ antialias: true }); + const renderer = new THREE.WebGLRenderer({ antialias: false }); renderer.setSize(w, h); - renderer.setPixelRatio(Math.min(window.devicePixelRatio, 1.25)); + renderer.setPixelRatio(Math.min(window.devicePixelRatio, 1.0)); renderer.outputColorSpace = THREE.SRGBColorSpace; while (container.firstChild) container.removeChild(container.firstChild); container.appendChild(renderer.domElement); @@ -303,7 +324,7 @@ export default function FractalOrb({ stream, settings }: Props) { blending: THREE.AdditiveBlending, }); - const geometry = new THREE.SphereGeometry(2.0, 128, 128); + const geometry = new THREE.SphereGeometry(2.0, 64, 64); const orb = new THREE.Mesh(geometry, material); scene.add(orb); @@ -313,7 +334,7 @@ export default function FractalOrb({ stream, settings }: Props) { // Post-processing const composer = new EffectComposer(renderer); - composer.setPixelRatio(Math.min(window.devicePixelRatio, 1.25)); + composer.setPixelRatio(Math.min(window.devicePixelRatio, 1.0)); composer.setSize(w, h); composer.addPass(new RenderPass(scene, camera)); const caPass = new ShaderPass(ChromaticAberrationShader); @@ -324,32 +345,82 @@ export default function FractalOrb({ stream, settings }: Props) { const localCam = new THREE.Vector3(); const tmpPrimary = new THREE.Color(); const tmpSecondary = new THREE.Color(); - let smoothedAmp = 0; + let smoothedBass = 0; + let smoothedMids = 0; + let smoothedHighs = 0; const draw = () => { animationRef.current = requestAnimationFrame(draw); const s = settingsRef.current; + const now = performance.now(); + // Frequency band analysis (matching AudioDebug Hz-based boundaries) analyser.getByteFrequencyData(dataArray); - const binCount = dataArray.length; - const bassEnd = Math.floor(binCount * 0.15); - const midsEnd = Math.floor(binCount * 0.55); - let bassSum = 0, midsSum = 0, highsSum = 0; - for (let i = 0; i < bassEnd; i++) bassSum += dataArray[i]; - for (let i = bassEnd; i < midsEnd; i++) midsSum += dataArray[i]; - for (let i = midsEnd; i < binCount; i++) highsSum += dataArray[i]; - const bass = (bassSum / (bassEnd * 255)) * s.sensitivity; - const mids = (midsSum / ((midsEnd - bassEnd) * 255)) * s.sensitivity; - const highs = (highsSum / ((binCount - midsEnd) * 255)) * s.sensitivity; - const amp = (bass + mids + highs) / 3; - smoothedAmp += (amp - smoothedAmp) * 0.15; + const sampleRate = audioCtx.sampleRate; + const binHz = sampleRate / analyser.fftSize; + + const subBassEnd = Math.min(Math.floor(60 / binHz), bufferLength); + const bassEnd = Math.min(Math.floor(250 / binHz), bufferLength); + const midEnd = Math.min(Math.floor(2000 / binHz), bufferLength); + const highMidEnd = Math.min(Math.floor(6000 / binHz), bufferLength); + + const bandEnergy = (start: number, end: number) => { + let sum = 0; + for (let i = start; i < end; i++) sum += dataArray[i]; + return end > start ? (sum / ((end - start) * 255)) : 0; + }; + + const subBass = bandEnergy(0, subBassEnd) * s.sensitivity; + const bass = bandEnergy(subBassEnd, bassEnd) * s.sensitivity; + const mids = bandEnergy(bassEnd, midEnd) * s.sensitivity; + const highMids = bandEnergy(midEnd, highMidEnd) * s.sensitivity; + const highs = bandEnergy(highMidEnd, bufferLength) * s.sensitivity; + + // Smooth the values for visual response + smoothedBass += ((subBass + bass) * 0.5 - smoothedBass) * 0.2; + smoothedMids += (mids - smoothedMids) * 0.15; + smoothedHighs += ((highMids + highs) * 0.5 - smoothedHighs) * 0.15; + + // Kick detection using spectral flux (matching AudioDebug algorithm) + kickAnalyser.getByteFrequencyData(kickFreqData); + const kickBinHz = sampleRate / kickAnalyser.fftSize; + const kickBassEnd2 = Math.min(Math.floor(150 / kickBinHz), kickBufferLength); + + let flux = 0; + for (let i = 1; i < kickBassEnd2; i++) { + const diff = kickFreqData[i] - prevKickFreqData[i]; + if (diff > 0) flux += diff; + } + flux /= kickBassEnd2 * 255; + + for (let i = 0; i < kickBufferLength; i++) { + prevKickFreqData[i] = kickFreqData[i]; + } + + fluxHistory.push(flux); + if (fluxHistory.length > FLUX_HISTORY_SIZE) fluxHistory.shift(); + + // Adaptive threshold: median + factor * stddev + floor + const sortedFlux = [...fluxHistory].sort((a, b) => a - b); + const medianFlux = sortedFlux[Math.floor(sortedFlux.length / 2)] || 0; + const meanFlux = fluxHistory.reduce((a, b) => a + b, 0) / fluxHistory.length; + const stdFlux = Math.sqrt(fluxHistory.reduce((a, b) => a + (b - meanFlux) ** 2, 0) / fluxHistory.length); + const kickThreshold = medianFlux + stdFlux * 1.2 + 0.02; + + const isKick = flux > kickThreshold && (now - lastKickTime) > KICK_COOLDOWN; + if (isKick) { + lastKickTime = now; + kickEnergy = 1.0; + } + // Decay kick energy for visual response + kickEnergy *= 0.88; const delta = clock.getDelta(); - orbUniforms.uTime.value += delta * (0.5 + mids * 0.6) * s.speed; - orbUniforms.uDensity.value = BASE_DENSITY * (1.0 + bass * 0.8); - orbUniforms.uInternalAnim.value = BASE_INTERNAL_ANIM * (1.0 + mids * 1.2); - atmosphereUniforms.uGlow.value = BASE_GLOW + bass * 0.6; - caPass.uniforms.uAmount.value = BASE_CA + highs * 0.04; + orbUniforms.uTime.value += delta * (0.5 + smoothedMids * 0.6) * s.speed; + orbUniforms.uDensity.value = BASE_DENSITY * (1.0 + smoothedBass * 0.8 + kickEnergy * 1.5); + orbUniforms.uInternalAnim.value = BASE_INTERNAL_ANIM * (1.0 + smoothedMids * 1.2); + atmosphereUniforms.uGlow.value = BASE_GLOW + smoothedBass * 0.6 + kickEnergy * 0.8; + caPass.uniforms.uAmount.value = BASE_CA + smoothedHighs * 0.04 + kickEnergy * 0.06; // Hue shift the base colors const hueDelta = s.hueShift / 360; @@ -359,8 +430,8 @@ export default function FractalOrb({ stream, settings }: Props) { orbUniforms.uSecondaryColor.value.copy(tmpSecondary); atmosphereUniforms.uColor.value.copy(tmpPrimary); - // Pulse + scale - const targetScale = s.scale * (1.0 + smoothedAmp * 0.08); + // Pulse + scale — kicks cause a visible pop + const targetScale = s.scale * (1.0 + smoothedBass * 0.08 + kickEnergy * 0.15); orb.scale.setScalar(targetScale); // Auto-rotation @@ -400,6 +471,7 @@ export default function FractalOrb({ stream, settings }: Props) { atmosphereMaterial.dispose(); composer.dispose(); renderer.dispose(); + kickAnalyser.disconnect(); }; }, [stream]); diff --git a/src/components/visualizers/Shambhala.tsx b/src/components/visualizers/Shambhala.tsx new file mode 100644 index 0000000..c884376 --- /dev/null +++ b/src/components/visualizers/Shambhala.tsx @@ -0,0 +1,303 @@ +import { useEffect, useRef } from 'react'; +import * as THREE from 'three'; +import { VisualizerSettings } from '../../types'; + +interface Props { + stream: MediaStream; + settings: VisualizerSettings; +} + +const vertexShader = ` + void main() { + gl_Position = vec4(position, 1.0); + } +`; + +const fragmentShader = ` + uniform float iTime; + uniform vec2 iResolution; + uniform float u_speed; + uniform float u_glowIntensity; + uniform float u_exposure; + uniform float u_voxelResolution; + uniform float u_tunnelRadius; + uniform vec3 u_colorPhasePrimary; + uniform vec3 u_colorPhaseSecondary; + uniform vec3 u_foldingAxis; + + #define MAX_RAY_STEPS 110 + #define STEP_MULTIPLIER 0.23 + + vec3 custom_tanh(vec3 x) { + vec3 e = exp(2.0 * x); + return (e - 1.0) / (e + 1.0); + } + + vec3 applySpaceReflection(vec3 position, vec3 axis) { + return 36.4 * dot(axis, position) * axis - position; + } + + vec3 calculateDisplacement(vec3 pos, float scale) { + return (1.1 / scale) * sin(pos.zxy * scale + 3.0 * scale); + } + + void main() { + vec2 uv = (gl_FragCoord.xy * 2.0 - iResolution.xy) / iResolution.y; + + vec3 rayOrigin = vec3(0.0, 0.0, iTime * u_speed); + vec3 rayDir = normalize(vec3(uv, 1.0)); + + vec3 foldingAxis = normalize(tan(u_foldingAxis)); + + float traveledDistance = 0.5; + vec3 accumulatedColor = vec3(0.0); + + float tRot = iTime * 0.3; + mat2 timeRotation = mat2(cos(tRot), cos(tRot + 8.0), cos(tRot + 30.0), cos(tRot)); + + vec3 precomputedCoreAnim = u_glowIntensity * exp(sin(iTime * 2.0 + u_colorPhaseSecondary)); + + vec3 currentPhase = u_colorPhasePrimary; + + for (int stepIndex = 0; stepIndex < MAX_RAY_STEPS; stepIndex++) { + vec3 currentPos = rayOrigin + rayDir * traveledDistance; + + float tunnelDist = max(abs(currentPos.x), abs(currentPos.y)) - u_tunnelRadius; + + vec3 fractalPos = currentPos; + + fractalPos.z = abs(mod(fractalPos.z, 7.0) - 3.5); + fractalPos = applySpaceReflection(fractalPos, foldingAxis); + + fractalPos = log(abs(fractalPos) + 1.03); + + fractalPos = ceil(fractalPos * u_voxelResolution) / u_voxelResolution; + + float maxIter = 21.0 - clamp(traveledDistance * 0.7, 0.0, 16.0); + + for (float iter = 2.0; iter <= 20.9; iter += 1.6) { + if (iter > maxIter) break; + fractalPos += calculateDisplacement(fractalPos, iter); + } + + fractalPos.yz *= timeRotation; + fractalPos += calculateDisplacement(fractalPos, 1.0); + + float localDensity = abs(tunnelDist) + abs(fractalPos.x) * 0.15 + 0.04; + + vec3 primaryEmission = exp(sin(currentPhase)) / localDensity; + currentPhase += 0.2; + + float sqDistToCenter = dot(currentPos.xy, currentPos.xy); + vec3 coreEmission = precomputedCoreAnim / (sqDistToCenter + 1.5); + + accumulatedColor += primaryEmission * 0.5 + coreEmission * 0.05; + + traveledDistance += localDensity * STEP_MULTIPLIER; + + if (traveledDistance > 45.0 || max(accumulatedColor.x, max(accumulatedColor.y, accumulatedColor.z)) > 1000.0) { + break; + } + } + + gl_FragColor = vec4(custom_tanh(accumulatedColor / u_exposure), 1.0); + } +`; + +// Rotate a vec3 around the (1,1,1) axis by `angle` radians — used so the +// global hueShift setting can sweep the color-phase vectors through the +// chromatic spectrum without losing their relative offsets. +function rotateAroundAxis(v: THREE.Vector3, angle: number): THREE.Vector3 { + const axis = new THREE.Vector3(1, 1, 1).normalize(); + const cosA = Math.cos(angle); + const sinA = Math.sin(angle); + const dot = v.x * axis.x + v.y * axis.y + v.z * axis.z; + return new THREE.Vector3( + v.x * cosA + (axis.y * v.z - axis.z * v.y) * sinA + axis.x * dot * (1 - cosA), + v.y * cosA + (axis.z * v.x - axis.x * v.z) * sinA + axis.y * dot * (1 - cosA), + v.z * cosA + (axis.x * v.y - axis.y * v.x) * sinA + axis.z * dot * (1 - cosA), + ); +} + +export default function Shambhala({ stream, settings }: Props) { + const containerRef = useRef(null); + const animationRef = useRef(null); + const audioCtxRef = useRef(null); + const sourceRef = useRef(null); + const settingsRef = useRef(settings); + + useEffect(() => { + settingsRef.current = settings; + }, [settings]); + + useEffect(() => { + if (!containerRef.current) return; + + const container = containerRef.current; + const w = container.clientWidth; + const h = container.clientHeight; + + const audioCtx = new (window.AudioContext || (window as any).webkitAudioContext)(); + audioCtxRef.current = audioCtx; + + const analyser = audioCtx.createAnalyser(); + analyser.fftSize = 2048; + analyser.smoothingTimeConstant = 0.75; + + const source = audioCtx.createMediaStreamSource(stream); + source.connect(analyser); + sourceRef.current = source; + + const freqBins = analyser.frequencyBinCount; + const freqData = new Uint8Array(freqBins); + + const DPR = Math.min(window.devicePixelRatio, 1.0) * 0.6; + + const renderer = new THREE.WebGLRenderer({ antialias: false }); + renderer.setPixelRatio(DPR); + renderer.setSize(w, h); + while (container.firstChild) container.removeChild(container.firstChild); + container.appendChild(renderer.domElement); + + const scene = new THREE.Scene(); + const camera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 1); + + const BASE_SPEED = 0.3; + const BASE_GLOW = 15.8; + const BASE_EXPOSURE = 256.5; + const BASE_VOXEL = 55.0; + const BASE_TUNNEL_RADIUS = 3.3; + const BASE_PRIMARY_PHASE = new THREE.Vector3(0.1, 2.0, 4.0); + const BASE_SECONDARY_PHASE = new THREE.Vector3(0.0, 1.0, 2.0); + const BASE_FOLDING = new THREE.Vector3(3.145, 1.79, 7.81); + + const uniforms = { + iTime: { value: 0.0 }, + iResolution: { value: new THREE.Vector2(w * DPR, h * DPR) }, + u_speed: { value: BASE_SPEED }, + u_glowIntensity: { value: BASE_GLOW }, + u_exposure: { value: BASE_EXPOSURE }, + u_voxelResolution: { value: BASE_VOXEL }, + u_tunnelRadius: { value: BASE_TUNNEL_RADIUS }, + u_colorPhasePrimary: { value: BASE_PRIMARY_PHASE.clone() }, + u_colorPhaseSecondary: { value: BASE_SECONDARY_PHASE.clone() }, + u_foldingAxis: { value: BASE_FOLDING.clone() }, + }; + + const material = new THREE.ShaderMaterial({ + vertexShader, + fragmentShader, + uniforms, + }); + + const quad = new THREE.Mesh(new THREE.PlaneGeometry(2, 2), material); + scene.add(quad); + + const clock = new THREE.Clock(); + let smoothedSubBass = 0; + let smoothedBass = 0; + let smoothedMids = 0; + let smoothedHighs = 0; + let phaseDrift = 0; + + const draw = () => { + animationRef.current = requestAnimationFrame(draw); + const s = settingsRef.current; + + analyser.getByteFrequencyData(freqData); + const sampleRate = audioCtx.sampleRate; + const binHz = sampleRate / analyser.fftSize; + + const subBassEnd = Math.min(Math.floor(60 / binHz), freqBins); + const bassEnd = Math.min(Math.floor(250 / binHz), freqBins); + const midEnd = Math.min(Math.floor(2000 / binHz), freqBins); + const highMidEnd = Math.min(Math.floor(6000 / binHz), freqBins); + + const bandEnergy = (start: number, end: number) => { + let sum = 0; + for (let i = start; i < end; i++) sum += freqData[i]; + return end > start ? sum / ((end - start) * 255) : 0; + }; + + const subBass = bandEnergy(0, subBassEnd) * s.sensitivity; + const bass = bandEnergy(subBassEnd, bassEnd) * s.sensitivity; + const mids = bandEnergy(bassEnd, midEnd) * s.sensitivity; + const highMids = bandEnergy(midEnd, highMidEnd) * s.sensitivity; + const highs = bandEnergy(highMidEnd, freqBins) * s.sensitivity; + + smoothedSubBass += (subBass - smoothedSubBass) * 0.25; + smoothedBass += (bass - smoothedBass) * 0.2; + smoothedMids += (mids - smoothedMids) * 0.15; + smoothedHighs += ((highMids + highs) * 0.5 - smoothedHighs) * 0.15; + + const delta = clock.getDelta(); + phaseDrift += delta * (0.3 + smoothedHighs * 1.5); + + uniforms.iTime.value += delta * s.speed; + + uniforms.u_speed.value = BASE_SPEED * s.speed * (1.0 + smoothedBass * 2.5); + uniforms.u_glowIntensity.value = BASE_GLOW + smoothedSubBass * 35.0; + // Lower exposure = brighter image, so highs drive a treble bloom. + uniforms.u_exposure.value = Math.max(60.0, BASE_EXPOSURE - smoothedHighs * 140.0); + uniforms.u_voxelResolution.value = BASE_VOXEL + smoothedMids * 40.0; + + // Tunnel expands with bass and grows/shrinks with the scale setting. + const scaleFactor = Math.max(0.5, Math.min(3.0, s.scale)); + uniforms.u_tunnelRadius.value = (BASE_TUNNEL_RADIUS + smoothedBass * 1.2) * scaleFactor; + + // Folding axes wobble subtly with mids — geometry morphs to melody. + const wobble = smoothedMids * 0.4; + uniforms.u_foldingAxis.value.set( + BASE_FOLDING.x + Math.sin(phaseDrift * 0.7) * wobble, + BASE_FOLDING.y + Math.cos(phaseDrift * 0.9) * wobble, + BASE_FOLDING.z + Math.sin(phaseDrift * 1.1) * wobble, + ); + + // Color phases drift over time + nudged by their respective bands. + const primaryDrift = phaseDrift * 0.5; + const secondaryDrift = phaseDrift * 0.3 + smoothedBass * 2.0; + const driftedPrimary = new THREE.Vector3( + BASE_PRIMARY_PHASE.x + Math.sin(primaryDrift) * 0.6, + BASE_PRIMARY_PHASE.y + Math.sin(primaryDrift + 2.0) * 0.6, + BASE_PRIMARY_PHASE.z + Math.sin(primaryDrift + 4.0) * 0.6, + ); + const driftedSecondary = new THREE.Vector3( + BASE_SECONDARY_PHASE.x + Math.sin(secondaryDrift) * 0.8, + BASE_SECONDARY_PHASE.y + Math.sin(secondaryDrift + 1.5) * 0.8, + BASE_SECONDARY_PHASE.z + Math.sin(secondaryDrift + 3.0) * 0.8, + ); + + // hueShift rotates the phase vectors around the (1,1,1) axis so the + // perceived palette sweeps through the spectrum. + const hueRad = (s.hueShift / 360) * Math.PI * 2; + uniforms.u_colorPhasePrimary.value.copy(rotateAroundAxis(driftedPrimary, hueRad)); + uniforms.u_colorPhaseSecondary.value.copy(rotateAroundAxis(driftedSecondary, hueRad)); + + renderer.render(scene, camera); + }; + + draw(); + + const handleResize = () => { + const width = container.clientWidth; + const height = container.clientHeight; + renderer.setSize(width, height); + uniforms.iResolution.value.set(width * DPR, height * DPR); + }; + window.addEventListener('resize', handleResize); + + return () => { + window.removeEventListener('resize', handleResize); + if (animationRef.current) cancelAnimationFrame(animationRef.current); + if (sourceRef.current) sourceRef.current.disconnect(); + if (audioCtxRef.current && audioCtxRef.current.state !== 'closed') { + audioCtxRef.current.close(); + } + quad.geometry.dispose(); + material.dispose(); + renderer.dispose(); + }; + }, [stream]); + + return
; +} diff --git a/src/components/visualizers/TrailsStream.tsx b/src/components/visualizers/TrailsStream.tsx new file mode 100644 index 0000000..2a6e4f2 --- /dev/null +++ b/src/components/visualizers/TrailsStream.tsx @@ -0,0 +1,539 @@ +import { useEffect, useRef } from 'react'; +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 { ShaderPass } from 'three/examples/jsm/postprocessing/ShaderPass.js'; +import { OutputPass } from 'three/examples/jsm/postprocessing/OutputPass.js'; +import { VisualizerSettings } from '../../types'; + +interface Props { + stream: MediaStream; + settings: VisualizerSettings; +} + +const LINES_COUNT = 100; +const TUBE_TUBULAR_SEGMENTS = 200; +const TUBE_RADIAL_SEGMENTS = 8; + +const BASE_EXPOSURE = 3.6505; +const BASE_BRIGHTNESS = 4.0131; +const BASE_BLOOM_STRENGTH = 0.20; +const MAX_BLOOM_STRENGTH = 0.40; +const BASE_BLOOM_RADIUS = 0.294; +const BASE_BLOOM_THRESHOLD = 0.0; +const BASE_BLUR_STRENGTH = 3.5; +const BASE_GLOBAL_SPEED = 0.1; + +const DOT_DENSITY = 70; +const DOT_SIZE = 0.25; +const DOT_SPEED = 1.5; + +const ARC_RADIUS = 10.0; +const BEND_START_Z = -150.0; +const FLOOR_LENGTH = 132.75; +const WALL_HEIGHT = 200.0; + +const BASE_CAM_Z = 140; +const BASE_CAM_Y = 20; + +const PALETTE = ['#004c94', '#2e89ff', '#003994', '#004bad', '#ff5900']; + +class CycCurve extends THREE.Curve { + x: number; + zStart: number; + zBend: number; + radius: number; + yEnd: number; + L_flat: number; + L_arc: number; + L_up: number; + totalLength: number; + + constructor(x: number, zStart: number, zBend: number, radius: number, yEnd: number) { + super(); + this.x = x; + this.zStart = zStart; + this.zBend = zBend; + this.radius = radius; + this.yEnd = yEnd; + this.L_flat = Math.abs(zStart - (zBend + radius)); + this.L_arc = (Math.PI * radius) * 0.5; + this.L_up = Math.max(0.1, yEnd - radius); + this.totalLength = this.L_flat + this.L_arc + this.L_up; + } + + getPoint(t: number, optionalTarget: THREE.Vector3 = new THREE.Vector3()): THREE.Vector3 { + const d = t * this.totalLength; + let py = 0; + let pz = 0; + if (d <= this.L_flat) { + pz = this.zStart - d; + } else if (d <= this.L_flat + this.L_arc) { + const norm = (d - this.L_flat) / this.L_arc; + const eased = norm * norm * (3.0 - 2.0 * norm); + const angle = (norm * 0.4 + eased * 0.6) * (Math.PI * 0.5); + py = this.radius * (1.0 - Math.cos(angle)); + pz = (this.zBend + this.radius) - Math.sin(angle) * this.radius; + } else { + py = this.radius + (d - (this.L_flat + this.L_arc)); + pz = this.zBend; + } + return optionalTarget.set(this.x, py, pz); + } +} + +const trailVertexShader = ` + varying vec2 vUv; + varying vec3 vNormal; + varying vec3 vViewPosition; + void main() { + vUv = uv; + vNormal = normalMatrix * normal; + vec4 mvPosition = modelViewMatrix * vec4(position, 1.0); + vViewPosition = -mvPosition.xyz; + gl_Position = projectionMatrix * mvPosition; + } +`; + +const trailFragmentShader = ` + varying vec2 vUv; + varying vec3 vNormal; + varying vec3 vViewPosition; + uniform float uTime; + uniform vec3 uColor; + uniform float uSpeed; + uniform float uOffset; + uniform float uTailLength; + uniform float uIntensityMultiplier; + uniform float uBendUv; + uniform float uIsReflection; + uniform float uDotDensity; + uniform float uDotSize; + uniform float uDotSpeed; + uniform float uBrightness; + + float hash(vec2 p) { + return fract(sin(dot(p, vec2(12.9898, 78.233))) * 43758.5453); + } + + void main() { + float t = fract(uTime * uSpeed + uOffset); + float dist = fract(t - vUv.x + 1.0); + + float baseAlpha = smoothstep(uTailLength, 0.0, dist); + baseAlpha = pow(max(0.0, baseAlpha), 1.2); + + vec3 viewDir = normalize(vViewPosition); + float fresnel = abs(dot(normalize(vNormal), viewDir)); + float edgeSoftness = smoothstep(0.0, 0.02, fresnel); + baseAlpha *= edgeSoftness; + + float core = pow(max(0.0, baseAlpha), 3.0) * 1.5; + + float movingUV = vUv.x - (uTime * uSpeed * uDotSpeed) - uOffset; + float signalPos = movingUV * uDotDensity; + float dotId = floor(signalPos); + float dotLocal = fract(signalPos); + + float distToCenter = length(vec2((dotLocal - 0.5) * 2.0, (fract(vUv.y + 0.5) - 0.5) * 6.0)); + float dotShape = 1.0 - smoothstep(0.0, max(0.001, uDotSize), distToCenter); + float dotFinal = dotShape * step(0.6, hash(vec2(dotId, uOffset))) * (sin(uTime * 4.0 + hash(vec2(dotId)) * 6.28) * 0.3 + 0.7) * baseAlpha; + + if (uIsReflection > 0.5) { + float refFade = 1.0 - smoothstep(uBendUv - 0.015, uBendUv, vUv.x); + baseAlpha *= refFade; + core *= refFade; + dotFinal *= refFade * 0.1; + baseAlpha = pow(max(0.0, baseAlpha), 0.5) * (0.7 + hash(vUv * 300.0 + uTime * 0.05) * 0.3); + core *= 0.3; + } + + vec3 trailColor = uColor * (baseAlpha + core * 1.5) * uIntensityMultiplier * uBrightness; + vec3 rgb = trailColor / max(1.0 - clamp(dotFinal * 1.8, 0.0, 0.95), 0.001) + uColor * dotFinal * 2.5 * uIntensityMultiplier * uBrightness; + + gl_FragColor = vec4(rgb, (baseAlpha + dotFinal) * uIntensityMultiplier); + } +`; + +const foregroundBlurShader = { + uniforms: { + tDiffuse: { value: null as THREE.Texture | null }, + resolution: { value: new THREE.Vector2(1, 1) }, + blurStrength: { value: BASE_BLUR_STRENGTH }, + }, + vertexShader: ` + varying vec2 vUv; + void main() { + vUv = uv; + gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); + } + `, + fragmentShader: ` + uniform sampler2D tDiffuse; + uniform vec2 resolution; + uniform float blurStrength; + varying vec2 vUv; + void main() { + float mask = 1.0 - smoothstep(0.0, 0.35, vUv.y); + float radius = mask * blurStrength; + if (radius < 0.1) { + gl_FragColor = texture2D(tDiffuse, vUv); + } else { + vec4 color = vec4(0.0); + float total = 0.0; + const float GA = 2.3999632; + for (int i = 0; i < 32; i++) { + float f = float(i); + float r = sqrt(f) * radius; + float theta = f * GA; + vec2 offset = vec2(cos(theta), sin(theta)) * (r / resolution); + color += texture2D(tDiffuse, vUv + offset); + total += 1.0; + } + gl_FragColor = color / total; + } + } + `, +}; + +type TrailUniforms = { + uTime: { value: number }; + uColor: { value: THREE.Color }; + uColorIndex: { value: number }; + uSpeed: { value: number }; + uOffset: { value: number }; + uTailLength: { value: number }; + uIntensityMultiplier: { value: number }; + uBendUv: { value: number }; + uIsReflection: { value: number }; + uDotDensity: { value: number }; + uDotSize: { value: number }; + uDotSpeed: { value: number }; + uBrightness: { value: number }; +}; + +export default function TrailsStream({ stream, settings }: Props) { + const containerRef = useRef(null); + const animationRef = useRef(null); + const audioCtxRef = useRef(null); + const sourceRef = useRef(null); + const settingsRef = useRef(settings); + + useEffect(() => { + settingsRef.current = settings; + }, [settings]); + + useEffect(() => { + if (!containerRef.current) return; + const container = containerRef.current; + const w = container.clientWidth; + const h = container.clientHeight; + if (w === 0 || h === 0) return; + + // ---- Audio ---- + const audioCtx = new (window.AudioContext || (window as any).webkitAudioContext)(); + audioCtxRef.current = audioCtx; + + const analyser = audioCtx.createAnalyser(); + analyser.fftSize = 2048; + analyser.smoothingTimeConstant = 0.75; + + const source = audioCtx.createMediaStreamSource(stream); + source.connect(analyser); + sourceRef.current = source; + + const freqBins = analyser.frequencyBinCount; + const freqData = new Uint8Array(freqBins); + + let smoothedSubBass = 0; + let smoothedBass = 0; + let smoothedMids = 0; + let smoothedHighs = 0; + + // ---- Renderer ---- + const DPR = 1.6; + const renderer = new THREE.WebGLRenderer({ antialias: true, powerPreference: 'high-performance' }); + renderer.setPixelRatio(DPR); + renderer.setSize(w, h); + renderer.setClearColor(0x000000, 1); + renderer.toneMapping = THREE.LinearToneMapping; + renderer.toneMappingExposure = BASE_EXPOSURE; + while (container.firstChild) container.removeChild(container.firstChild); + container.appendChild(renderer.domElement); + + // ---- Scene & camera ---- + const scene = new THREE.Scene(); + scene.background = new THREE.Color(0x000000); + const camera = new THREE.PerspectiveCamera(55, w / h, 1, 2000); + camera.position.set(0, BASE_CAM_Y, BASE_CAM_Z); + camera.lookAt(0, BASE_CAM_Y, -50); + + // ---- Post-processing ---- + const renderTarget = new THREE.WebGLRenderTarget(w * DPR, h * DPR, { + type: THREE.HalfFloatType, + format: THREE.RGBAFormat, + }); + const composer = new EffectComposer(renderer, renderTarget); + composer.setPixelRatio(DPR); + composer.addPass(new RenderPass(scene, camera)); + const bloomPass = new UnrealBloomPass( + new THREE.Vector2(w, h), + BASE_BLOOM_STRENGTH, + BASE_BLOOM_RADIUS, + BASE_BLOOM_THRESHOLD, + ); + composer.addPass(bloomPass); + const blurPass = new ShaderPass(foregroundBlurShader); + blurPass.uniforms.resolution.value.set(w * DPR, h * DPR); + composer.addPass(blurPass); + composer.addPass(new OutputPass()); + + // ---- Cached base palette HSL for hue rotation ---- + const baseColors = PALETTE.map(hex => new THREE.Color(hex)); + const baseHSL = baseColors.map(c => { + const hsl = { h: 0, s: 0, l: 0 }; + c.getHSL(hsl); + return hsl; + }); + const tmpColor = new THREE.Color(); + + // ---- Floor (bent plane) ---- + const buildFloorGeometry = () => { + const floorGeo = new THREE.PlaneGeometry(1000, 1000, 1, 1500); + floorGeo.rotateX(-Math.PI * 0.5); + const pos = floorGeo.attributes.position.array as Float32Array; + for (let i = 0; i < pos.length; i += 3) { + if (pos[i + 2] < BEND_START_Z) { + const d = BEND_START_Z - pos[i + 2]; + const maxA = ARC_RADIUS * Math.PI * 0.5; + if (d < maxA) { + const n = d / maxA; + const a = (n * 0.4 + (n * n * (3.0 - 2.0 * n)) * 0.6) * (Math.PI * 0.5); + pos[i + 1] = ARC_RADIUS * (1.0 - Math.cos(a)); + pos[i + 2] = BEND_START_Z - Math.sin(a) * ARC_RADIUS; + } else { + pos[i + 1] = ARC_RADIUS + (d - maxA); + pos[i + 2] = BEND_START_Z - ARC_RADIUS; + } + } + } + floorGeo.computeVertexNormals(); + return floorGeo; + }; + + const floorMat = new THREE.MeshBasicMaterial({ + color: 0x000000, + transparent: true, + opacity: 0.85, + depthWrite: false, + side: THREE.DoubleSide, + }); + const floorMesh = new THREE.Mesh(buildFloorGeometry(), floorMat); + floorMesh.position.set(0, -0.5, -0.5); + floorMesh.renderOrder = 1; + scene.add(floorMesh); + + // ---- Trails ---- + const trailsGroup = new THREE.Group(); + scene.add(trailsGroup); + + const bendUv = + Math.abs(FLOOR_LENGTH - BEND_START_Z) / + (Math.abs(FLOOR_LENGTH - BEND_START_Z) + (Math.PI * ARC_RADIUS) * 0.5 + Math.max(0.1, WALL_HEIGHT - ARC_RADIUS)); + + type TrailEntry = { + mainMat: THREE.ShaderMaterial; + refMat: THREE.ShaderMaterial; + mainGeo: THREE.TubeGeometry; + refGeo: THREE.TubeGeometry; + colorIdx: number; + baseTailLength: number; + }; + const trails: TrailEntry[] = []; + + for (let i = 0; i < LINES_COUNT; i++) { + const normIdx = (i / (LINES_COUNT - 1)) * 2 - 1; + const linearPos = normIdx; + const expPos = Math.sign(normIdx) * Math.pow(Math.abs(normIdx), 1.2); + let startX = (linearPos * 0.5 + expPos * 0.5) * 80; + startX += (Math.random() - 0.5) * 2.0; + + const thickness = Math.random() * 0.2 + 0.1; + const colorIdx = Math.floor(Math.random() * PALETTE.length); + const baseTailLength = Math.random() * 0.4 + 0.3; + + const uniforms: TrailUniforms = { + uTime: { value: 0 }, + uColor: { value: baseColors[colorIdx].clone() }, + uColorIndex: { value: colorIdx }, + uSpeed: { value: Math.random() * 0.5 + 0.2 }, + uOffset: { value: Math.random() }, + uTailLength: { value: baseTailLength }, + uIntensityMultiplier: { value: 1.0 }, + uBendUv: { value: bendUv }, + uIsReflection: { value: 0.0 }, + uDotDensity: { value: DOT_DENSITY }, + uDotSize: { value: DOT_SIZE }, + uDotSpeed: { value: DOT_SPEED }, + uBrightness: { value: BASE_BRIGHTNESS }, + }; + + const material = new THREE.ShaderMaterial({ + vertexShader: trailVertexShader, + fragmentShader: trailFragmentShader, + uniforms: uniforms as unknown as { [k: string]: THREE.IUniform }, + transparent: true, + depthWrite: false, + blending: THREE.AdditiveBlending, + }); + + const path = new CycCurve(startX, FLOOR_LENGTH, BEND_START_Z - ARC_RADIUS, ARC_RADIUS, WALL_HEIGHT); + const tubeGeo = new THREE.TubeGeometry(path, TUBE_TUBULAR_SEGMENTS, thickness, TUBE_RADIAL_SEGMENTS, false); + + const mesh = new THREE.Mesh(tubeGeo, material); + + const refMaterial = material.clone(); + refMaterial.uniforms.uIntensityMultiplier.value = 0.4; + refMaterial.uniforms.uIsReflection.value = 1.0; + const refTubeGeo = new THREE.TubeGeometry(path, TUBE_TUBULAR_SEGMENTS, thickness, TUBE_RADIAL_SEGMENTS, false); + const refMesh = new THREE.Mesh(refTubeGeo, refMaterial); + refMesh.scale.y = -1; + refMesh.position.y = -1.0; + + trailsGroup.add(mesh, refMesh); + trails.push({ + mainMat: material, + refMat: refMaterial, + mainGeo: tubeGeo, + refGeo: refTubeGeo, + colorIdx, + baseTailLength, + }); + } + + // ---- Animation loop ---- + const clock = new THREE.Clock(); + let globalTime = 0; + + const draw = () => { + animationRef.current = requestAnimationFrame(draw); + const s = settingsRef.current; + const dt = Math.min(clock.getDelta(), 0.1); + + // --- 5-band energies (sensitivity baked at source) --- + analyser.getByteFrequencyData(freqData); + const sampleRate = audioCtx.sampleRate; + const binHz = sampleRate / analyser.fftSize; + const subBassEnd = Math.min(Math.floor(60 / binHz), freqBins); + const bassEnd = Math.min(Math.floor(250 / binHz), freqBins); + const midEnd = Math.min(Math.floor(2000 / binHz), freqBins); + const highMidEnd = Math.min(Math.floor(6000 / binHz), freqBins); + + const bandEnergy = (start: number, end: number) => { + if (end <= start) return 0; + let sum = 0; + for (let i = start; i < end; i++) sum += freqData[i]; + return sum / ((end - start) * 255); + }; + + const subBass = bandEnergy(0, subBassEnd) * s.sensitivity; + const bass = bandEnergy(subBassEnd, bassEnd) * s.sensitivity; + const mids = bandEnergy(bassEnd, midEnd) * s.sensitivity; + const highMids = bandEnergy(midEnd, highMidEnd) * s.sensitivity; + const highs = bandEnergy(highMidEnd, freqBins) * s.sensitivity; + + smoothedSubBass += (subBass - smoothedSubBass) * 0.25; + smoothedBass += (bass - smoothedBass) * 0.20; + smoothedMids += (mids - smoothedMids) * 0.15; + smoothedHighs += ((highMids + highs) * 0.5 - smoothedHighs) * 0.15; + + // Bass boosts forward flow + globalTime += dt * BASE_GLOBAL_SPEED * s.speed * (1 + smoothedBass * 2.5); + + // --- Effective values --- + bloomPass.strength = Math.min(MAX_BLOOM_STRENGTH, BASE_BLOOM_STRENGTH + smoothedSubBass * 0.20); + const brightness = BASE_BRIGHTNESS * (1 + smoothedSubBass * 0.9); + const dotSpeed = DOT_SPEED * (1 + smoothedHighs * 2.5); + const dotSize = DOT_SIZE * (1 + smoothedBass * 0.6); + const tailLengthMul = 1 + smoothedMids * 0.9; + + // --- Hue-shifted palette this frame --- + const hueDelta = s.hueShift / 360; + const shifted: THREE.Color[] = baseHSL.map((hsl) => { + tmpColor.setHSL(((hsl.h + hueDelta) % 1 + 1) % 1, hsl.s, hsl.l); + return tmpColor.clone(); + }); + + // --- Push to every trail uniform --- + for (let i = 0; i < trails.length; i++) { + const t = trails[i]; + const main = t.mainMat.uniforms; + const ref = t.refMat.uniforms; + const tailLen = Math.min(0.95, t.baseTailLength * tailLengthMul); + main.uTime.value = globalTime; + ref.uTime.value = globalTime; + main.uBrightness.value = brightness; + ref.uBrightness.value = brightness; + main.uDotSpeed.value = dotSpeed; + ref.uDotSpeed.value = dotSpeed; + main.uDotSize.value = dotSize; + ref.uDotSize.value = dotSize; + main.uTailLength.value = tailLen; + ref.uTailLength.value = tailLen; + main.uColor.value.copy(shifted[t.colorIdx]); + ref.uColor.value.copy(shifted[t.colorIdx]); + } + + // --- Camera dolly from scale --- + const clampedScale = Math.max(0.25, Math.min(4.0, s.scale)); + camera.position.z = BASE_CAM_Z / clampedScale; + camera.lookAt(0, BASE_CAM_Y, -50); + + composer.render(); + }; + draw(); + + // ---- Resize ---- + const handleResize = () => { + const width = container.clientWidth; + const height = container.clientHeight; + if (width === 0 || height === 0) return; + camera.aspect = width / height; + camera.updateProjectionMatrix(); + renderer.setSize(width, height); + composer.setSize(width, height); + bloomPass.setSize(width, height); + blurPass.uniforms.resolution.value.set(width * DPR, height * DPR); + }; + window.addEventListener('resize', handleResize); + + // ---- Cleanup ---- + return () => { + if (animationRef.current) cancelAnimationFrame(animationRef.current); + window.removeEventListener('resize', handleResize); + if (sourceRef.current) sourceRef.current.disconnect(); + if (audioCtxRef.current && audioCtxRef.current.state !== 'closed') { + audioCtxRef.current.close(); + } + floorMesh.geometry.dispose(); + floorMat.dispose(); + for (const t of trails) { + t.mainGeo.dispose(); + t.refGeo.dispose(); + t.mainMat.dispose(); + t.refMat.dispose(); + } + bloomPass.dispose(); + composer.dispose(); + renderTarget.dispose(); + renderer.dispose(); + renderer.forceContextLoss(); + if (renderer.domElement.parentNode === container) { + container.removeChild(renderer.domElement); + } + }; + }, [stream]); + + return
; +} diff --git a/src/index.css b/src/index.css index f1d8c73..816bb7a 100644 --- a/src/index.css +++ b/src/index.css @@ -1 +1,120 @@ @import "tailwindcss"; + +.skin-90s { + image-rendering: pixelated; +} + + +.skin-90s input[type="range"] { + height: 20px; +} + +.skin-90s input[type="range"]::-webkit-slider-thumb { + -webkit-appearance: none; + width: 11px; + height: 20px; + background: #c0c0c0; + border: 2px solid; + border-color: white #808080 #808080 white; + cursor: pointer; +} + +.skin-90s input[type="range"]::-webkit-slider-runnable-track { + height: 4px; + background: #808080; + border: 1px solid; + border-color: #808080 white white #808080; +} + + +.skin-winamp input[type="range"] { + -webkit-appearance: none; + appearance: none; + height: 14px; + background: transparent; +} + +.skin-winamp input[type="range"]::-webkit-slider-runnable-track { + height: 6px; + background: #0a0a14; + border: 2px solid; + border-color: #1a1a2a #6a6a7a #6a6a7a #1a1a2a; +} + +.skin-winamp input[type="range"]::-webkit-slider-thumb { + -webkit-appearance: none; + width: 14px; + height: 14px; + margin-top: -6px; + background: #3a3a4a; + border: 2px solid; + border-color: #6a6a7a #1a1a2a #1a1a2a #6a6a7a; + cursor: pointer; +} + +.skin-crt { + text-shadow: 0 0 4px rgba(0, 255, 0, 0.6); +} + +.skin-crt::before { + content: ''; + position: fixed; + inset: 0; + background: repeating-linear-gradient( + 0deg, + transparent, + transparent 2px, + rgba(0, 0, 0, 0.15) 2px, + rgba(0, 0, 0, 0.15) 4px + ); + pointer-events: none; + z-index: 100; +} + +.skin-crt::after { + content: ''; + position: fixed; + inset: 0; + background: radial-gradient(ellipse at center, transparent 60%, rgba(0, 0, 0, 0.4) 100%); + pointer-events: none; + z-index: 99; +} + + +.skin-crt input[type="range"] { + -webkit-appearance: none; + appearance: none; + height: 10px; + background: transparent; +} + +.skin-crt input[type="range"]::-webkit-slider-runnable-track { + height: 2px; + background: #00ff00; + box-shadow: 0 0 4px rgba(0, 255, 0, 0.4); +} + +.skin-crt input[type="range"]::-webkit-slider-thumb { + -webkit-appearance: none; + width: 10px; + height: 10px; + margin-top: -4px; + background: #00ff00; + border: none; + cursor: pointer; + box-shadow: 0 0 6px rgba(0, 255, 0, 0.6); +} + +@keyframes crt-flicker { + 0% { opacity: 1; } + 5% { opacity: 0.97; } + 10% { opacity: 1; } + 15% { opacity: 0.98; } + 20% { opacity: 1; } + 100% { opacity: 1; } +} + +.skin-crt > * { + animation: crt-flicker 8s infinite; +} + diff --git a/src/skins.ts b/src/skins.ts new file mode 100644 index 0000000..917680d --- /dev/null +++ b/src/skins.ts @@ -0,0 +1,215 @@ +export type SkinType = 'modern' | 'win95' | 'winamp' | 'crt'; + +export interface SkinDefinition { + name: string; + label: string; + root: string; + header: string; + headerBorder: string; + body: string; + buttonPrimary: string; + buttonSecondary: string; + buttonDanger: string; + buttonGhost: string; + select: string; + selectOption: string; + settingsPanel: string; + settingsLabel: string; + settingsValue: string; + settingsSlider: string; + settingsDescription: string; + settingsButton: string; + dialog: string; + dialogOverlay: string; + dialogInput: string; + dialogButtonPrimary: string; + dialogButtonSecondary: string; + errorBanner: string; + mobileHint: string; + title: string; + subtitle: string; + heroIcon: string; + heroTitle: string; + heroText: string; + versionLabel: string; + atmosphericBg: boolean; + sendspinBar: string; + sendspinTrackTitle: string; + sendspinTrackArtist: string; + sendspinButton: string; + sendspinButtonActive: string; + sendspinPlayButton: string; + sendspinDivider: string; + sendspinVolumeSlider: string; +} + +export const skins: Record = { + modern: { + name: 'modern', + label: 'Modern', + root: 'min-h-screen bg-black text-white flex flex-col font-sans relative overflow-hidden', + header: 'p-6 flex justify-between items-center bg-black/20 backdrop-blur-md border-b border-white/10 transition-all duration-300', + headerBorder: '', + body: 'flex-1 flex flex-col items-center justify-center relative overflow-hidden', + buttonPrimary: 'flex items-center gap-2 px-4 py-2 rounded-full bg-purple-600/80 hover:bg-purple-500 transition-colors border border-purple-400/30 text-sm shadow-[0_0_15px_rgba(147,51,234,0.3)] cursor-pointer', + buttonSecondary: 'flex items-center gap-2 px-4 py-2 rounded-full bg-white/10 hover:bg-white/20 transition-colors border border-white/5 text-sm cursor-pointer', + buttonDanger: 'flex items-center gap-2 px-4 py-2 rounded-full bg-red-500/20 hover:bg-red-500/40 text-red-400 transition-colors border border-red-500/30 text-sm cursor-pointer', + buttonGhost: 'flex items-center gap-2 px-4 py-2 rounded-full bg-white/5 hover:bg-white/10 transition-colors border border-white/5 text-white/70 hover:text-white text-sm cursor-pointer', + select: 'appearance-none bg-white/10 hover:bg-white/20 border border-white/10 rounded-full pl-4 pr-10 py-2 text-sm text-white focus:outline-none focus:ring-2 focus:ring-purple-500 cursor-pointer transition-colors', + selectOption: 'bg-gray-900', + settingsPanel: 'absolute top-0 right-0 bottom-0 w-80 bg-black/80 backdrop-blur-xl border-l border-white/10 p-6 transform transition-transform duration-300 z-50', + settingsLabel: 'text-sm text-white/70', + settingsValue: 'text-sm text-purple-400', + settingsSlider: 'w-full accent-purple-500', + settingsDescription: 'text-xs text-white/40 mt-2', + settingsButton: 'w-full py-2 mt-4 rounded-lg bg-white/5 hover:bg-white/10 border border-white/10 text-sm transition-colors cursor-pointer', + dialog: 'bg-gray-900 border border-white/10 rounded-2xl p-6 w-full max-w-md space-y-4 mx-4', + dialogOverlay: 'fixed inset-0 bg-black/60 backdrop-blur-sm z-[100] flex items-center justify-center', + dialogInput: 'w-full bg-white/10 border border-white/10 rounded-lg px-4 py-2 text-white placeholder-white/30 focus:outline-none focus:ring-2 focus:ring-purple-500', + dialogButtonPrimary: 'px-4 py-2 rounded-lg bg-purple-600/80 hover:bg-purple-500 border border-purple-400/30 text-sm transition-colors cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed', + dialogButtonSecondary: 'px-4 py-2 rounded-lg bg-white/5 hover:bg-white/10 border border-white/10 text-sm transition-colors cursor-pointer', + errorBanner: 'fixed top-6 left-1/2 -translate-x-1/2 bg-red-500/20 border border-red-500/50 text-red-200 px-6 py-3 rounded-xl backdrop-blur-md z-[110] flex items-center gap-3', + mobileHint: 'md:hidden flex items-center justify-center gap-2 bg-white/5 border-b border-white/10 px-4 py-2 text-xs text-red-400 tracking-wide', + title: 'text-2xl font-light tracking-widest uppercase', + subtitle: 'mt-1 text-xs tracking-[0.2em] text-white/60', + heroIcon: 'w-24 h-24 mx-auto border border-white/10 rounded-full flex items-center justify-center bg-white/5 backdrop-blur-sm', + heroTitle: 'text-3xl font-light', + heroText: 'text-white/50 font-light leading-relaxed', + versionLabel: 'absolute bottom-3 left-4 text-[10px] tracking-[0.18em] uppercase text-white/25 pointer-events-none select-none z-40', + atmosphericBg: true, + sendspinBar: 'pointer-events-auto bg-black/70 backdrop-blur-xl border border-white/10 rounded-t-2xl px-6 py-3 flex items-center gap-4', + sendspinTrackTitle: 'text-sm text-white truncate max-w-[200px]', + sendspinTrackArtist: 'text-xs text-white/50 truncate max-w-[200px]', + sendspinButton: 'p-2 rounded-full hover:bg-white/10 text-white/70 hover:text-white transition-colors cursor-pointer disabled:opacity-30 disabled:cursor-not-allowed', + sendspinButtonActive: 'text-purple-400', + sendspinPlayButton: 'p-2 rounded-full bg-white/10 hover:bg-white/20 text-white transition-colors cursor-pointer disabled:opacity-30 disabled:cursor-not-allowed', + sendspinDivider: 'w-px h-6 bg-white/10', + sendspinVolumeSlider: 'w-20 accent-purple-500 disabled:opacity-30', + }, + win95: { + name: 'win95', + label: 'Windows 95', + root: 'min-h-screen bg-[#008080] text-black flex flex-col font-["MS_Sans_Serif",_"Microsoft_Sans_Serif",_Tahoma,_sans-serif] relative overflow-hidden skin-90s', + header: 'p-2 flex justify-between items-center bg-[#c0c0c0] border-b-2 border-r-2 border-[#808080] border-t-2 border-l-2 border-t-white border-l-white transition-all duration-300', + headerBorder: '', + body: 'flex-1 flex flex-col items-center justify-center relative overflow-hidden', + buttonPrimary: 'flex items-center gap-2 px-4 py-1.5 bg-[#c0c0c0] border-2 border-t-white border-l-white border-b-[#808080] border-r-[#808080] text-sm text-black font-bold cursor-pointer active:border-t-[#808080] active:border-l-[#808080] active:border-b-white active:border-r-white', + buttonSecondary: 'flex items-center gap-2 px-4 py-1.5 bg-[#c0c0c0] border-2 border-t-white border-l-white border-b-[#808080] border-r-[#808080] text-sm text-black cursor-pointer active:border-t-[#808080] active:border-l-[#808080] active:border-b-white active:border-r-white', + buttonDanger: 'flex items-center gap-2 px-4 py-1.5 bg-[#c0c0c0] border-2 border-t-white border-l-white border-b-[#808080] border-r-[#808080] text-sm text-red-700 font-bold cursor-pointer active:border-t-[#808080] active:border-l-[#808080] active:border-b-white active:border-r-white', + buttonGhost: 'flex items-center gap-2 px-4 py-1.5 bg-[#c0c0c0] border-2 border-t-white border-l-white border-b-[#808080] border-r-[#808080] text-sm text-black cursor-pointer active:border-t-[#808080] active:border-l-[#808080] active:border-b-white active:border-r-white', + select: 'appearance-none bg-white border-2 border-t-[#808080] border-l-[#808080] border-b-white border-r-white pl-2 pr-8 py-1 text-sm text-black focus:outline-none cursor-pointer', + selectOption: 'bg-white text-black', + settingsPanel: 'absolute top-0 right-0 bottom-0 w-80 bg-[#c0c0c0] border-l-2 border-l-white border-t-2 border-t-white p-4 transform transition-transform duration-300 z-50', + settingsLabel: 'text-sm text-black font-bold', + settingsValue: 'text-sm text-[#000080]', + settingsSlider: 'w-full accent-[#000080]', + settingsDescription: 'text-xs text-[#808080] mt-1', + settingsButton: 'w-full py-1.5 mt-4 bg-[#c0c0c0] border-2 border-t-white border-l-white border-b-[#808080] border-r-[#808080] text-sm font-bold cursor-pointer active:border-t-[#808080] active:border-l-[#808080] active:border-b-white active:border-r-white', + dialog: 'bg-[#c0c0c0] border-2 border-t-white border-l-white border-b-[#808080] border-r-[#808080] p-1 w-full max-w-md space-y-3 mx-4', + dialogOverlay: 'fixed inset-0 bg-[#008080]/80 z-[100] flex items-center justify-center', + dialogInput: 'w-full bg-white border-2 border-t-[#808080] border-l-[#808080] border-b-white border-r-white px-2 py-1 text-black placeholder-[#808080] focus:outline-none', + dialogButtonPrimary: 'px-4 py-1.5 bg-[#c0c0c0] border-2 border-t-white border-l-white border-b-[#808080] border-r-[#808080] text-sm font-bold cursor-pointer disabled:text-[#808080] active:border-t-[#808080] active:border-l-[#808080] active:border-b-white active:border-r-white', + dialogButtonSecondary: 'px-4 py-1.5 bg-[#c0c0c0] border-2 border-t-white border-l-white border-b-[#808080] border-r-[#808080] text-sm cursor-pointer active:border-t-[#808080] active:border-l-[#808080] active:border-b-white active:border-r-white', + errorBanner: 'fixed top-6 left-1/2 -translate-x-1/2 bg-white border-2 border-t-white border-l-white border-b-[#808080] border-r-[#808080] text-red-700 px-4 py-2 z-[110] flex items-center gap-3', + mobileHint: 'md:hidden flex items-center justify-center gap-2 bg-[#ffff00] border-b-2 border-b-[#808080] px-4 py-1 text-xs text-black font-bold', + title: 'text-lg font-bold uppercase text-[#000080]', + subtitle: 'text-xs text-[#808080]', + heroIcon: 'w-24 h-24 mx-auto border-2 border-t-[#808080] border-l-[#808080] border-b-white border-r-white flex items-center justify-center bg-white', + heroTitle: 'text-2xl font-bold text-[#000080]', + heroText: 'text-black leading-relaxed', + versionLabel: 'absolute bottom-3 left-4 text-[10px] text-[#808080] pointer-events-none select-none z-40', + atmosphericBg: false, + sendspinBar: 'pointer-events-auto bg-[#c0c0c0] border-2 border-t-white border-l-white border-b-[#808080] border-r-[#808080] px-4 py-2 flex items-center gap-3', + sendspinTrackTitle: 'text-sm text-black truncate max-w-[200px] font-bold', + sendspinTrackArtist: 'text-xs text-[#808080] truncate max-w-[200px]', + sendspinButton: 'p-1.5 bg-[#c0c0c0] border-2 border-t-white border-l-white border-b-[#808080] border-r-[#808080] text-black cursor-pointer disabled:text-[#808080] active:border-t-[#808080] active:border-l-[#808080] active:border-b-white active:border-r-white', + sendspinButtonActive: 'text-[#000080]', + sendspinPlayButton: 'p-1.5 bg-[#c0c0c0] border-2 border-t-white border-l-white border-b-[#808080] border-r-[#808080] text-black cursor-pointer disabled:text-[#808080] active:border-t-[#808080] active:border-l-[#808080] active:border-b-white active:border-r-white', + sendspinDivider: 'w-px h-6 bg-[#808080]', + sendspinVolumeSlider: 'w-20 accent-[#000080] disabled:opacity-50', + }, + winamp: { + name: 'winamp', + label: 'Winamp', + root: 'min-h-screen bg-[#3a3a4a] text-[#00ff00] flex flex-col font-["Arial_Narrow",_Arial,_sans-serif] relative overflow-hidden skin-winamp', + header: 'p-4 flex justify-between items-center bg-[#3a3a4a] border-2 border-t-[#6a6a7a] border-l-[#6a6a7a] border-b-[#1a1a2a] border-r-[#1a1a2a] transition-all duration-300', + headerBorder: '', + body: 'flex-1 flex flex-col items-center justify-center relative overflow-hidden', + buttonPrimary: 'flex items-center gap-2 px-4 py-2 bg-[#3a3a4a] border-2 border-t-[#6a6a7a] border-l-[#6a6a7a] border-b-[#1a1a2a] border-r-[#1a1a2a] text-sm text-[#d0d0d0] font-bold uppercase cursor-pointer active:border-t-[#1a1a2a] active:border-l-[#1a1a2a] active:border-b-[#6a6a7a] active:border-r-[#6a6a7a]', + buttonSecondary: 'flex items-center gap-2 px-4 py-2 bg-[#3a3a4a] border-2 border-t-[#6a6a7a] border-l-[#6a6a7a] border-b-[#1a1a2a] border-r-[#1a1a2a] text-sm text-[#d0d0d0] uppercase cursor-pointer active:border-t-[#1a1a2a] active:border-l-[#1a1a2a] active:border-b-[#6a6a7a] active:border-r-[#6a6a7a]', + buttonDanger: 'flex items-center gap-2 px-4 py-2 bg-[#3a3a4a] border-2 border-t-[#6a6a7a] border-l-[#6a6a7a] border-b-[#1a1a2a] border-r-[#1a1a2a] text-sm text-[#ff6666] font-bold uppercase cursor-pointer active:border-t-[#1a1a2a] active:border-l-[#1a1a2a] active:border-b-[#6a6a7a] active:border-r-[#6a6a7a]', + buttonGhost: 'flex items-center gap-2 px-4 py-2 bg-[#3a3a4a] border-2 border-t-[#6a6a7a] border-l-[#6a6a7a] border-b-[#1a1a2a] border-r-[#1a1a2a] text-sm text-[#a0a0a0] uppercase cursor-pointer active:border-t-[#1a1a2a] active:border-l-[#1a1a2a] active:border-b-[#6a6a7a] active:border-r-[#6a6a7a]', + select: 'appearance-none bg-[#0a0a14] border-2 border-t-[#1a1a2a] border-l-[#1a1a2a] border-b-[#6a6a7a] border-r-[#6a6a7a] pl-4 pr-10 py-2 text-sm text-[#00ff00] focus:outline-none cursor-pointer', + selectOption: 'bg-[#0a0a14] text-[#00ff00]', + settingsPanel: 'absolute top-0 right-0 bottom-0 w-80 bg-[#3a3a4a] border-l-2 border-l-[#6a6a7a] p-6 transform transition-transform duration-300 z-50', + settingsLabel: 'text-sm text-[#d0d0d0] uppercase tracking-wider', + settingsValue: 'text-sm text-[#00ff00] font-bold', + settingsSlider: 'w-full accent-[#00ff00]', + settingsDescription: 'text-xs text-[#808090] mt-2', + settingsButton: 'w-full py-2 mt-4 bg-[#3a3a4a] border-2 border-t-[#6a6a7a] border-l-[#6a6a7a] border-b-[#1a1a2a] border-r-[#1a1a2a] text-sm text-[#d0d0d0] uppercase cursor-pointer active:border-t-[#1a1a2a] active:border-l-[#1a1a2a] active:border-b-[#6a6a7a] active:border-r-[#6a6a7a]', + dialog: 'bg-[#3a3a4a] border-2 border-t-[#6a6a7a] border-l-[#6a6a7a] border-b-[#1a1a2a] border-r-[#1a1a2a] p-5 w-full max-w-md space-y-4 mx-4', + dialogOverlay: 'fixed inset-0 bg-[#1a1a2a]/90 z-[100] flex items-center justify-center', + dialogInput: 'w-full bg-[#0a0a14] border-2 border-t-[#1a1a2a] border-l-[#1a1a2a] border-b-[#6a6a7a] border-r-[#6a6a7a] px-4 py-2 text-[#00ff00] text-sm placeholder-[#00ff00]/30 focus:outline-none', + dialogButtonPrimary: 'px-4 py-2 bg-[#3a3a4a] border-2 border-t-[#6a6a7a] border-l-[#6a6a7a] border-b-[#1a1a2a] border-r-[#1a1a2a] text-sm text-[#d0d0d0] font-bold uppercase cursor-pointer disabled:text-[#606070] active:border-t-[#1a1a2a] active:border-l-[#1a1a2a] active:border-b-[#6a6a7a] active:border-r-[#6a6a7a]', + dialogButtonSecondary: 'px-4 py-2 bg-[#3a3a4a] border-2 border-t-[#6a6a7a] border-l-[#6a6a7a] border-b-[#1a1a2a] border-r-[#1a1a2a] text-sm text-[#a0a0a0] uppercase cursor-pointer active:border-t-[#1a1a2a] active:border-l-[#1a1a2a] active:border-b-[#6a6a7a] active:border-r-[#6a6a7a]', + errorBanner: 'fixed top-6 left-1/2 -translate-x-1/2 bg-[#3a3a4a] border-2 border-t-[#6a6a7a] border-l-[#6a6a7a] border-b-[#1a1a2a] border-r-[#1a1a2a] text-[#ff6666] px-6 py-3 z-[110] flex items-center gap-3 text-sm', + mobileHint: 'md:hidden flex items-center justify-center gap-2 bg-[#3a3a4a] border-b-2 border-b-[#1a1a2a] px-4 py-2 text-xs text-[#ff6666]', + title: 'text-xl font-bold uppercase tracking-[0.2em] text-[#d0d0d0]', + subtitle: 'mt-1 text-xs tracking-wider text-[#808090]', + heroIcon: 'w-24 h-24 mx-auto border-2 border-t-[#1a1a2a] border-l-[#1a1a2a] border-b-[#6a6a7a] border-r-[#6a6a7a] flex items-center justify-center bg-[#0a0a14]', + heroTitle: 'text-3xl font-bold text-[#00ff00] tracking-wide', + heroText: 'text-[#00ff00]/60 text-sm leading-relaxed', + versionLabel: 'absolute bottom-3 left-4 text-[10px] tracking-wider text-[#606070] pointer-events-none select-none z-40', + atmosphericBg: false, + sendspinBar: 'pointer-events-auto bg-[#3a3a4a] border-2 border-t-[#6a6a7a] border-l-[#6a6a7a] border-b-[#1a1a2a] border-r-[#1a1a2a] px-6 py-3 flex items-center gap-4', + sendspinTrackTitle: 'text-sm text-[#00ff00] truncate max-w-[200px] font-bold', + sendspinTrackArtist: 'text-xs text-[#00ff00]/50 truncate max-w-[200px]', + sendspinButton: 'p-2 bg-[#3a3a4a] border-2 border-t-[#6a6a7a] border-l-[#6a6a7a] border-b-[#1a1a2a] border-r-[#1a1a2a] text-[#a0a0a0] hover:text-[#d0d0d0] cursor-pointer disabled:text-[#606070] disabled:cursor-not-allowed active:border-t-[#1a1a2a] active:border-l-[#1a1a2a] active:border-b-[#6a6a7a] active:border-r-[#6a6a7a]', + sendspinButtonActive: 'text-[#00ff00]', + sendspinPlayButton: 'p-2 bg-[#3a3a4a] border-2 border-t-[#6a6a7a] border-l-[#6a6a7a] border-b-[#1a1a2a] border-r-[#1a1a2a] text-[#d0d0d0] cursor-pointer disabled:text-[#606070] disabled:cursor-not-allowed active:border-t-[#1a1a2a] active:border-l-[#1a1a2a] active:border-b-[#6a6a7a] active:border-r-[#6a6a7a]', + sendspinDivider: 'w-px h-6 bg-[#1a1a2a]', + sendspinVolumeSlider: 'w-20 accent-[#00ff00] disabled:opacity-30', + }, + crt: { + name: 'crt', + label: 'CRT Monitor', + root: 'min-h-screen bg-[#0a0a0a] text-[#00ff00] flex flex-col font-["Courier_New",_Courier,_monospace] relative overflow-hidden skin-crt', + header: 'p-4 flex justify-between items-center bg-[#0a0a0a] border-b border-[#00ff00]/20 transition-all duration-300', + headerBorder: '', + body: 'flex-1 flex flex-col items-center justify-center relative overflow-hidden', + buttonPrimary: 'flex items-center gap-2 px-3 py-1.5 bg-transparent border border-[#00ff00] text-sm text-[#00ff00] font-bold cursor-pointer hover:bg-[#00ff00]/10 hover:shadow-[0_0_8px_rgba(0,255,0,0.3)]', + buttonSecondary: 'flex items-center gap-2 px-3 py-1.5 bg-transparent border border-[#00ff00]/50 text-sm text-[#00ff00]/80 cursor-pointer hover:border-[#00ff00] hover:text-[#00ff00] hover:shadow-[0_0_8px_rgba(0,255,0,0.2)]', + buttonDanger: 'flex items-center gap-2 px-3 py-1.5 bg-transparent border border-[#ff3333]/70 text-sm text-[#ff3333] cursor-pointer hover:bg-[#ff3333]/10 hover:shadow-[0_0_8px_rgba(255,51,51,0.3)]', + buttonGhost: 'flex items-center gap-2 px-3 py-1.5 bg-transparent border border-[#00ff00]/30 text-sm text-[#00ff00]/60 cursor-pointer hover:border-[#00ff00]/60 hover:text-[#00ff00]', + select: 'appearance-none bg-[#0a0a0a] border border-[#00ff00]/50 pl-2 pr-8 py-1 text-sm text-[#00ff00] focus:outline-none focus:border-[#00ff00] focus:shadow-[0_0_6px_rgba(0,255,0,0.3)] cursor-pointer', + selectOption: 'bg-[#0a0a0a] text-[#00ff00]', + settingsPanel: 'absolute top-0 right-0 bottom-0 w-80 bg-[#0a0a0a] border-l border-[#00ff00]/20 p-5 transform transition-transform duration-300 z-50', + settingsLabel: 'text-xs text-[#00ff00]/70 uppercase tracking-widest', + settingsValue: 'text-xs text-[#00ff00] font-bold', + settingsSlider: 'w-full accent-[#00ff00]', + settingsDescription: 'text-[10px] text-[#00ff00]/30 mt-1 tracking-wide', + settingsButton: 'w-full py-1.5 mt-4 bg-transparent border border-[#00ff00]/50 text-sm text-[#00ff00]/80 cursor-pointer hover:border-[#00ff00] hover:text-[#00ff00] hover:shadow-[0_0_8px_rgba(0,255,0,0.2)]', + dialog: 'bg-[#0a0a0a] border border-[#00ff00]/40 p-5 w-full max-w-md space-y-4 mx-4 shadow-[0_0_20px_rgba(0,255,0,0.1)]', + dialogOverlay: 'fixed inset-0 bg-black/90 z-[100] flex items-center justify-center', + dialogInput: 'w-full bg-[#0a0a0a] border border-[#00ff00]/50 px-3 py-1.5 text-[#00ff00] text-sm placeholder-[#00ff00]/25 focus:outline-none focus:border-[#00ff00] focus:shadow-[0_0_6px_rgba(0,255,0,0.3)]', + dialogButtonPrimary: 'px-4 py-1.5 bg-transparent border border-[#00ff00] text-sm text-[#00ff00] font-bold cursor-pointer disabled:opacity-30 disabled:cursor-not-allowed hover:bg-[#00ff00]/10 hover:shadow-[0_0_8px_rgba(0,255,0,0.3)]', + dialogButtonSecondary: 'px-4 py-1.5 bg-transparent border border-[#00ff00]/50 text-sm text-[#00ff00]/80 cursor-pointer hover:border-[#00ff00] hover:text-[#00ff00]', + errorBanner: 'fixed top-6 left-1/2 -translate-x-1/2 bg-[#0a0a0a] border border-[#ff3333] text-[#ff3333] px-5 py-2 z-[110] flex items-center gap-3 text-sm shadow-[0_0_12px_rgba(255,51,51,0.3)]', + mobileHint: 'md:hidden flex items-center justify-center gap-2 bg-[#0a0a0a] border-b border-[#ff3333]/30 px-4 py-1.5 text-xs text-[#ff3333]', + title: 'text-lg font-bold uppercase tracking-[0.3em] text-[#00ff00]', + subtitle: 'text-[10px] tracking-[0.2em] text-[#00ff00]/40', + heroIcon: 'w-24 h-24 mx-auto border border-[#00ff00]/30 flex items-center justify-center bg-[#0a0a0a] shadow-[0_0_20px_rgba(0,255,0,0.1)]', + heroTitle: 'text-2xl font-bold text-[#00ff00] tracking-wider', + heroText: 'text-[#00ff00]/50 text-sm leading-relaxed tracking-wide', + versionLabel: 'absolute bottom-3 left-4 text-[10px] tracking-[0.2em] text-[#00ff00]/20 pointer-events-none select-none z-40', + atmosphericBg: false, + sendspinBar: 'pointer-events-auto bg-[#0a0a0a] border border-[#00ff00]/30 px-5 py-2 flex items-center gap-4 shadow-[0_0_15px_rgba(0,255,0,0.05)]', + sendspinTrackTitle: 'text-sm text-[#00ff00] truncate max-w-[200px] font-bold tracking-wide', + sendspinTrackArtist: 'text-[10px] text-[#00ff00]/40 truncate max-w-[200px] tracking-wider', + sendspinButton: 'p-1.5 border border-[#00ff00]/30 text-[#00ff00]/60 hover:text-[#00ff00] hover:border-[#00ff00]/60 cursor-pointer disabled:opacity-20 disabled:cursor-not-allowed', + sendspinButtonActive: 'text-[#00ff00] border-[#00ff00]', + sendspinPlayButton: 'p-1.5 border border-[#00ff00] text-[#00ff00] hover:shadow-[0_0_8px_rgba(0,255,0,0.3)] cursor-pointer disabled:opacity-20 disabled:cursor-not-allowed', + sendspinDivider: 'w-px h-5 bg-[#00ff00]/20', + sendspinVolumeSlider: 'w-20 accent-[#00ff00] disabled:opacity-20', + }, +}; diff --git a/vite.config.ts b/vite.config.ts index 5e3c0f2..2f8dc7e 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -12,7 +12,7 @@ export default defineConfig(({mode}) => { __APP_VERSION__: JSON.stringify(appVersion), }, build: { - chunkSizeWarningLimit: 550, + chunkSizeWarningLimit: 600, }, resolve: { alias: {