openvoiceui 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +104 -0
- package/Dockerfile +30 -0
- package/LICENSE +21 -0
- package/README.md +638 -0
- package/SETUP.md +360 -0
- package/app.py +232 -0
- package/auto-approve-devices.js +111 -0
- package/cli/index.js +372 -0
- package/config/__init__.py +4 -0
- package/config/default.yaml +43 -0
- package/config/flags.yaml +67 -0
- package/config/loader.py +203 -0
- package/config/providers.yaml +71 -0
- package/config/speech_normalization.yaml +182 -0
- package/config/theme.json +4 -0
- package/data/greetings.json +25 -0
- package/default-pages/ai-image-creator.html +915 -0
- package/default-pages/bulk-image-uploader.html +492 -0
- package/default-pages/desktop.html +2865 -0
- package/default-pages/file-explorer.html +854 -0
- package/default-pages/interactive-map.html +655 -0
- package/default-pages/style-guide.html +1005 -0
- package/default-pages/website-setup.html +1623 -0
- package/deploy/openclaw/Dockerfile +46 -0
- package/deploy/openvoiceui.service +30 -0
- package/deploy/setup-nginx.sh +50 -0
- package/deploy/setup-sudo.sh +306 -0
- package/deploy/skill-runner/Dockerfile +19 -0
- package/deploy/skill-runner/requirements.txt +14 -0
- package/deploy/skill-runner/server.py +269 -0
- package/deploy/supertonic/Dockerfile +22 -0
- package/deploy/supertonic/server.py +79 -0
- package/docker-compose.pinokio.yml +11 -0
- package/docker-compose.yml +59 -0
- package/greetings.json +25 -0
- package/index.html +65 -0
- package/inject-device-identity.js +142 -0
- package/package.json +82 -0
- package/profiles/default.json +114 -0
- package/profiles/manager.py +354 -0
- package/profiles/schema.json +337 -0
- package/prompts/voice-system-prompt.md +149 -0
- package/providers/__init__.py +39 -0
- package/providers/base.py +63 -0
- package/providers/llm/__init__.py +12 -0
- package/providers/llm/base.py +71 -0
- package/providers/llm/clawdbot_provider.py +112 -0
- package/providers/llm/zai_provider.py +115 -0
- package/providers/registry.py +320 -0
- package/providers/stt/__init__.py +12 -0
- package/providers/stt/base.py +58 -0
- package/providers/stt/webspeech_provider.py +49 -0
- package/providers/stt/whisper_provider.py +100 -0
- package/providers/tts/__init__.py +20 -0
- package/providers/tts/base.py +91 -0
- package/providers/tts/groq_provider.py +74 -0
- package/providers/tts/supertonic_provider.py +72 -0
- package/requirements.txt +38 -0
- package/routes/__init__.py +10 -0
- package/routes/admin.py +515 -0
- package/routes/canvas.py +1315 -0
- package/routes/chat.py +51 -0
- package/routes/conversation.py +2158 -0
- package/routes/elevenlabs_hybrid.py +306 -0
- package/routes/greetings.py +98 -0
- package/routes/icons.py +279 -0
- package/routes/image_gen.py +364 -0
- package/routes/instructions.py +190 -0
- package/routes/music.py +838 -0
- package/routes/onboarding.py +43 -0
- package/routes/pi.py +62 -0
- package/routes/profiles.py +215 -0
- package/routes/report_issue.py +68 -0
- package/routes/static_files.py +533 -0
- package/routes/suno.py +664 -0
- package/routes/theme.py +81 -0
- package/routes/transcripts.py +199 -0
- package/routes/vision.py +348 -0
- package/routes/workspace.py +288 -0
- package/server.py +1510 -0
- package/services/__init__.py +1 -0
- package/services/auth.py +143 -0
- package/services/canvas_versioning.py +239 -0
- package/services/db_pool.py +107 -0
- package/services/gateway.py +16 -0
- package/services/gateway_manager.py +333 -0
- package/services/gateways/__init__.py +12 -0
- package/services/gateways/base.py +110 -0
- package/services/gateways/compat.py +264 -0
- package/services/gateways/openclaw.py +1134 -0
- package/services/health.py +100 -0
- package/services/memory_client.py +455 -0
- package/services/paths.py +26 -0
- package/services/speech_normalizer.py +285 -0
- package/services/tts.py +270 -0
- package/setup-config.js +262 -0
- package/sounds/air_horn.mp3 +0 -0
- package/sounds/bruh.mp3 +0 -0
- package/sounds/crowd_cheer.mp3 +0 -0
- package/sounds/gunshot.mp3 +0 -0
- package/sounds/impact.mp3 +0 -0
- package/sounds/lets_go.mp3 +0 -0
- package/sounds/record_stop.mp3 +0 -0
- package/sounds/rewind.mp3 +0 -0
- package/sounds/sad_trombone.mp3 +0 -0
- package/sounds/scratch_long.mp3 +0 -0
- package/sounds/yeah.mp3 +0 -0
- package/src/adapters/ClawdBotAdapter.js +264 -0
- package/src/adapters/_template.js +133 -0
- package/src/adapters/elevenlabs-classic.js +841 -0
- package/src/adapters/elevenlabs-hybrid.js +812 -0
- package/src/adapters/hume-evi.js +676 -0
- package/src/admin.html +1339 -0
- package/src/app.js +8802 -0
- package/src/core/Config.js +173 -0
- package/src/core/EmotionEngine.js +307 -0
- package/src/core/EventBridge.js +180 -0
- package/src/core/EventBus.js +117 -0
- package/src/core/VoiceSession.js +607 -0
- package/src/face/BaseFace.js +259 -0
- package/src/face/EyeFace.js +208 -0
- package/src/face/HaloSmokeFace.js +509 -0
- package/src/face/manifest.json +27 -0
- package/src/face/previews/eyes.svg +16 -0
- package/src/face/previews/orb.svg +29 -0
- package/src/features/MusicPlayer.js +620 -0
- package/src/features/Soundboard.js +128 -0
- package/src/providers/DeepgramSTT.js +472 -0
- package/src/providers/DeepgramStreamingSTT.js +766 -0
- package/src/providers/GroqSTT.js +559 -0
- package/src/providers/TTSPlayer.js +323 -0
- package/src/providers/WebSpeechSTT.js +479 -0
- package/src/providers/tts/BaseTTSProvider.js +81 -0
- package/src/providers/tts/HumeProvider.js +77 -0
- package/src/providers/tts/SupertonicProvider.js +174 -0
- package/src/providers/tts/index.js +140 -0
- package/src/shell/adapter-registry.js +154 -0
- package/src/shell/caller-bridge.js +35 -0
- package/src/shell/camera-bridge.js +28 -0
- package/src/shell/canvas-bridge.js +32 -0
- package/src/shell/commercial-bridge.js +44 -0
- package/src/shell/face-bridge.js +44 -0
- package/src/shell/music-bridge.js +60 -0
- package/src/shell/orchestrator.js +233 -0
- package/src/shell/profile-discovery.js +303 -0
- package/src/shell/sounds-bridge.js +28 -0
- package/src/shell/transcript-bridge.js +61 -0
- package/src/shell/waveform-bridge.js +33 -0
- package/src/styles/base.css +2862 -0
- package/src/styles/face.css +417 -0
- package/src/styles/pi-overrides.css +89 -0
- package/src/styles/theme-dark.css +67 -0
- package/src/test-tts.html +175 -0
- package/src/ui/AppShell.js +544 -0
- package/src/ui/ProfileSwitcher.js +228 -0
- package/src/ui/SessionControl.js +240 -0
- package/src/ui/face/FacePicker.js +195 -0
- package/src/ui/face/FaceRenderer.js +309 -0
- package/src/ui/settings/PlaylistEditor.js +366 -0
- package/src/ui/settings/SettingsPanel.css +684 -0
- package/src/ui/settings/SettingsPanel.js +419 -0
- package/src/ui/settings/TTSVoicePreview.js +210 -0
- package/src/ui/themes/ThemeManager.js +213 -0
- package/src/ui/visualizers/BaseVisualizer.js +29 -0
- package/src/ui/visualizers/PartyFXVisualizer.css +291 -0
- package/src/ui/visualizers/PartyFXVisualizer.js +637 -0
- package/static/emulators/jsdos/js-dos.css +1 -0
- package/static/emulators/jsdos/js-dos.js +22 -0
- package/static/favicon.svg +55 -0
- package/static/icons/apple-touch-icon.png +0 -0
- package/static/icons/favicon-32.png +0 -0
- package/static/icons/icon-192.png +0 -0
- package/static/icons/icon-512.png +0 -0
- package/static/install.html +449 -0
- package/static/manifest.json +26 -0
- package/static/sw.js +21 -0
- package/tts_providers/__init__.py +136 -0
- package/tts_providers/base_provider.py +319 -0
- package/tts_providers/groq_provider.py +155 -0
- package/tts_providers/hume_provider.py +226 -0
- package/tts_providers/providers_config.json +119 -0
- package/tts_providers/qwen3_provider.py +371 -0
- package/tts_providers/resemble_provider.py +315 -0
- package/tts_providers/supertonic_provider.py +557 -0
- package/tts_providers/supertonic_tts.py +399 -0
|
@@ -0,0 +1,637 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PartyFXVisualizer - Music-reactive party effects visualizer
|
|
3
|
+
* Extracted from index.html with performance optimizations.
|
|
4
|
+
*
|
|
5
|
+
* Effects: center glow, beat flash, screen shake, ripples, oscilloscope,
|
|
6
|
+
* orbiting particles, disco dots, equalizer bars (top/bottom/left/right)
|
|
7
|
+
*
|
|
8
|
+
* Implements the BaseVisualizer interface (window.VisualizerModule).
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
window.VisualizerModule = {
|
|
12
|
+
name: 'Party FX',
|
|
13
|
+
description: 'Full party effects with beat detection, particles, disco dots, and equalizer bars',
|
|
14
|
+
|
|
15
|
+
// Audio analysis
|
|
16
|
+
audioContext: null,
|
|
17
|
+
analyser: null,
|
|
18
|
+
sourceNode1: null,
|
|
19
|
+
sourceNode2: null,
|
|
20
|
+
frequencyData: null,
|
|
21
|
+
timeDomainData: null,
|
|
22
|
+
animationId: null,
|
|
23
|
+
|
|
24
|
+
// State
|
|
25
|
+
enabled: localStorage.getItem('visualizerEnabled') !== 'false',
|
|
26
|
+
autoplayEnabled: localStorage.getItem('musicAutoplay') === 'true',
|
|
27
|
+
currentPlaylist: 'library',
|
|
28
|
+
|
|
29
|
+
// Beat detection
|
|
30
|
+
bassHistory: [],
|
|
31
|
+
prevBassLevel: 0,
|
|
32
|
+
lastBeatTime: 0,
|
|
33
|
+
beatCooldown: 50,
|
|
34
|
+
audioSensitivity: 1.5,
|
|
35
|
+
|
|
36
|
+
// Frequency band ranges (for 2048 FFT at 44100Hz)
|
|
37
|
+
BANDS: {
|
|
38
|
+
subBass: { start: 0, end: 4 },
|
|
39
|
+
bass: { start: 4, end: 12 },
|
|
40
|
+
lowMid: { start: 12, end: 24 },
|
|
41
|
+
mid: { start: 24, end: 92 },
|
|
42
|
+
highMid: { start: 92, end: 186 },
|
|
43
|
+
treble: { start: 186, end: 1024 }
|
|
44
|
+
},
|
|
45
|
+
|
|
46
|
+
// Party effects
|
|
47
|
+
discoDots: [],
|
|
48
|
+
partyParticles: [],
|
|
49
|
+
|
|
50
|
+
// Constants
|
|
51
|
+
NUM_BARS: 25,
|
|
52
|
+
RIPPLE_POOL_SIZE: 6,
|
|
53
|
+
|
|
54
|
+
// --- Cached DOM refs (populated in init) ---
|
|
55
|
+
_dom: null,
|
|
56
|
+
// Cached bar arrays with pre-parsed multipliers
|
|
57
|
+
_vizBars: null, // [{el, mult}]
|
|
58
|
+
_sideBars: null, // [{el, mult}]
|
|
59
|
+
// Cached viz/side container elements for toggling .active
|
|
60
|
+
_vizContainers: null,
|
|
61
|
+
// Oscilloscope canvas cached dimensions
|
|
62
|
+
_oscW: 0,
|
|
63
|
+
_oscH: 0,
|
|
64
|
+
// Ripple pool
|
|
65
|
+
_ripplePool: null,
|
|
66
|
+
_rippleIndex: 0,
|
|
67
|
+
// Track if containers are already active (avoid redundant classList ops)
|
|
68
|
+
_containersActive: false,
|
|
69
|
+
|
|
70
|
+
async init() {
|
|
71
|
+
console.log('PartyFXVisualizer initializing...');
|
|
72
|
+
|
|
73
|
+
// Cache all DOM element references once
|
|
74
|
+
this._dom = {
|
|
75
|
+
partyContainer: document.getElementById('party-effects-container'),
|
|
76
|
+
centerGlow: document.getElementById('center-glow'),
|
|
77
|
+
beatFlash: document.getElementById('beat-flash'),
|
|
78
|
+
faceBox: document.getElementById('face-box'),
|
|
79
|
+
rippleContainer: document.getElementById('ripple-container'),
|
|
80
|
+
oscContainer: document.getElementById('oscilloscope-container'),
|
|
81
|
+
oscCanvas: document.getElementById('oscilloscope-canvas'),
|
|
82
|
+
oscCtx: null,
|
|
83
|
+
audio1: document.getElementById('music-player'),
|
|
84
|
+
audio2: document.getElementById('music-player-2'),
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
// Cache oscilloscope canvas context and size
|
|
88
|
+
if (this._dom.oscCanvas) {
|
|
89
|
+
this._dom.oscCtx = this._dom.oscCanvas.getContext('2d');
|
|
90
|
+
this._updateCanvasSize();
|
|
91
|
+
window.addEventListener('resize', () => this._updateCanvasSize());
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
this.createVisualizerBars();
|
|
95
|
+
this.initPartyEffects();
|
|
96
|
+
this._initRipplePool();
|
|
97
|
+
this.updateToggleUI();
|
|
98
|
+
|
|
99
|
+
// Cache the viz/side containers for .active toggling
|
|
100
|
+
this._vizContainers = document.querySelectorAll('.visualizer-container, .side-visualizer');
|
|
101
|
+
|
|
102
|
+
// Add ended listener for autoplay
|
|
103
|
+
if (this._dom.audio1) this._dom.audio1.addEventListener('ended', () => this.onTrackEnded());
|
|
104
|
+
if (this._dom.audio2) this._dom.audio2.addEventListener('ended', () => this.onTrackEnded());
|
|
105
|
+
|
|
106
|
+
// Listen for face mode changes — hide/show square bars dynamically
|
|
107
|
+
window.addEventListener('faceModeChanged', (e) => {
|
|
108
|
+
const isHalo = e.detail === 'halo-smoke';
|
|
109
|
+
if (this._vizContainers) {
|
|
110
|
+
this._vizContainers.forEach(el => {
|
|
111
|
+
if (isHalo) {
|
|
112
|
+
el.classList.remove('active');
|
|
113
|
+
} else if (this._containersActive) {
|
|
114
|
+
el.classList.add('active');
|
|
115
|
+
}
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
// Expose global toggle functions for HTML onclick handlers
|
|
121
|
+
window.toggleAutoplay = (enabled) => this.setAutoplay(enabled);
|
|
122
|
+
window.toggleVisualizer = (enabled) => this.setEnabled(enabled);
|
|
123
|
+
window.switchPlaylist = (playlist) => {
|
|
124
|
+
this.currentPlaylist = playlist;
|
|
125
|
+
if (window.musicPlayer && window.musicPlayer.switchPlaylist) {
|
|
126
|
+
window.musicPlayer.switchPlaylist(playlist);
|
|
127
|
+
}
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
console.log('PartyFXVisualizer ready, enabled:', this.enabled);
|
|
131
|
+
},
|
|
132
|
+
|
|
133
|
+
_updateCanvasSize() {
|
|
134
|
+
const canvas = this._dom.oscCanvas;
|
|
135
|
+
if (!canvas) return;
|
|
136
|
+
this._oscW = canvas.offsetWidth * 2;
|
|
137
|
+
this._oscH = canvas.offsetHeight * 2;
|
|
138
|
+
canvas.width = this._oscW;
|
|
139
|
+
canvas.height = this._oscH;
|
|
140
|
+
},
|
|
141
|
+
|
|
142
|
+
_initRipplePool() {
|
|
143
|
+
const container = this._dom.rippleContainer;
|
|
144
|
+
if (!container) return;
|
|
145
|
+
this._ripplePool = [];
|
|
146
|
+
for (let i = 0; i < this.RIPPLE_POOL_SIZE; i++) {
|
|
147
|
+
const ripple = document.createElement('div');
|
|
148
|
+
ripple.className = 'sound-ripple';
|
|
149
|
+
ripple.style.left = '50%';
|
|
150
|
+
ripple.style.top = '50%';
|
|
151
|
+
ripple.style.transform = 'translate(-50%, -50%)';
|
|
152
|
+
ripple.style.display = 'none';
|
|
153
|
+
container.appendChild(ripple);
|
|
154
|
+
this._ripplePool.push(ripple);
|
|
155
|
+
}
|
|
156
|
+
this._rippleIndex = 0;
|
|
157
|
+
},
|
|
158
|
+
|
|
159
|
+
onTrackEnded() {
|
|
160
|
+
if (this.autoplayEnabled && window.musicPlayer) {
|
|
161
|
+
console.log('Autoplaying next track...');
|
|
162
|
+
window.musicPlayer.play();
|
|
163
|
+
}
|
|
164
|
+
},
|
|
165
|
+
|
|
166
|
+
updateToggleUI() {
|
|
167
|
+
const autoplayToggle = document.getElementById('autoplay-toggle');
|
|
168
|
+
const visualizerToggle = document.getElementById('visualizer-toggle');
|
|
169
|
+
const autoplayCheckbox = document.getElementById('autoplay-checkbox');
|
|
170
|
+
const visualizerCheckbox = document.getElementById('visualizer-checkbox');
|
|
171
|
+
|
|
172
|
+
if (autoplayCheckbox) autoplayCheckbox.checked = this.autoplayEnabled;
|
|
173
|
+
if (visualizerCheckbox) visualizerCheckbox.checked = this.enabled;
|
|
174
|
+
if (autoplayToggle) autoplayToggle.classList.toggle('enabled', this.autoplayEnabled);
|
|
175
|
+
if (visualizerToggle) visualizerToggle.classList.toggle('enabled', this.enabled);
|
|
176
|
+
},
|
|
177
|
+
|
|
178
|
+
async setupAnalyser() {
|
|
179
|
+
if (!this.enabled) return;
|
|
180
|
+
|
|
181
|
+
try {
|
|
182
|
+
if (!this.audioContext) {
|
|
183
|
+
this.audioContext = new (window.AudioContext || window.webkitAudioContext)();
|
|
184
|
+
}
|
|
185
|
+
if (this.audioContext.state === 'suspended') {
|
|
186
|
+
await this.audioContext.resume();
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
if (!this.analyser) {
|
|
190
|
+
this.analyser = this.audioContext.createAnalyser();
|
|
191
|
+
this.analyser.fftSize = 2048;
|
|
192
|
+
this.analyser.smoothingTimeConstant = 0.8;
|
|
193
|
+
this.frequencyData = new Uint8Array(this.analyser.frequencyBinCount);
|
|
194
|
+
this.timeDomainData = new Uint8Array(this.analyser.fftSize);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
if (!this.sourceNode1 && this._dom.audio1) {
|
|
198
|
+
this.sourceNode1 = this.audioContext.createMediaElementSource(this._dom.audio1);
|
|
199
|
+
this.sourceNode1.connect(this.analyser);
|
|
200
|
+
}
|
|
201
|
+
if (!this.sourceNode2 && this._dom.audio2) {
|
|
202
|
+
this.sourceNode2 = this.audioContext.createMediaElementSource(this._dom.audio2);
|
|
203
|
+
this.sourceNode2.connect(this.analyser);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
this.analyser.connect(this.audioContext.destination);
|
|
207
|
+
console.log('Music analyser connected');
|
|
208
|
+
} catch (e) {
|
|
209
|
+
console.error('Visualizer analyser error:', e.message);
|
|
210
|
+
}
|
|
211
|
+
},
|
|
212
|
+
|
|
213
|
+
createVisualizerBars() {
|
|
214
|
+
const topViz = document.getElementById('top-viz');
|
|
215
|
+
const bottomViz = document.getElementById('bottom-viz');
|
|
216
|
+
const leftViz = document.getElementById('left-viz');
|
|
217
|
+
const rightViz = document.getElementById('right-viz');
|
|
218
|
+
|
|
219
|
+
if (!topViz || !bottomViz || !leftViz || !rightViz) return;
|
|
220
|
+
|
|
221
|
+
topViz.innerHTML = '';
|
|
222
|
+
bottomViz.innerHTML = '';
|
|
223
|
+
leftViz.innerHTML = '';
|
|
224
|
+
rightViz.innerHTML = '';
|
|
225
|
+
|
|
226
|
+
this._vizBars = [];
|
|
227
|
+
this._sideBars = [];
|
|
228
|
+
|
|
229
|
+
for (let i = 0; i < this.NUM_BARS; i++) {
|
|
230
|
+
const distFromCenter = Math.abs(i - (this.NUM_BARS - 1) / 2) / ((this.NUM_BARS - 1) / 2);
|
|
231
|
+
const centerMultiplier = 1 - (distFromCenter * 0.6);
|
|
232
|
+
|
|
233
|
+
const topBar = document.createElement('div');
|
|
234
|
+
topBar.className = 'visualizer-bar';
|
|
235
|
+
topBar.style.height = '10px';
|
|
236
|
+
topViz.appendChild(topBar);
|
|
237
|
+
this._vizBars.push({ el: topBar, mult: centerMultiplier });
|
|
238
|
+
|
|
239
|
+
const bottomBar = document.createElement('div');
|
|
240
|
+
bottomBar.className = 'visualizer-bar';
|
|
241
|
+
bottomBar.style.height = '10px';
|
|
242
|
+
bottomViz.appendChild(bottomBar);
|
|
243
|
+
this._vizBars.push({ el: bottomBar, mult: centerMultiplier });
|
|
244
|
+
|
|
245
|
+
const leftBar = document.createElement('div');
|
|
246
|
+
leftBar.className = 'side-bar';
|
|
247
|
+
leftBar.style.width = '20px';
|
|
248
|
+
leftViz.appendChild(leftBar);
|
|
249
|
+
this._sideBars.push({ el: leftBar, mult: centerMultiplier });
|
|
250
|
+
|
|
251
|
+
const rightBar = document.createElement('div');
|
|
252
|
+
rightBar.className = 'side-bar';
|
|
253
|
+
rightBar.style.width = '20px';
|
|
254
|
+
rightViz.appendChild(rightBar);
|
|
255
|
+
this._sideBars.push({ el: rightBar, mult: centerMultiplier });
|
|
256
|
+
}
|
|
257
|
+
},
|
|
258
|
+
|
|
259
|
+
initPartyEffects() {
|
|
260
|
+
const particleContainer = document.getElementById('party-particle-container');
|
|
261
|
+
if (particleContainer) {
|
|
262
|
+
particleContainer.innerHTML = '';
|
|
263
|
+
this.partyParticles = [];
|
|
264
|
+
for (let i = 0; i < 30; i++) {
|
|
265
|
+
const particle = document.createElement('div');
|
|
266
|
+
particle.className = 'party-particle';
|
|
267
|
+
particle.style.left = Math.random() * 100 + '%';
|
|
268
|
+
particle.style.top = Math.random() * 100 + '%';
|
|
269
|
+
particleContainer.appendChild(particle);
|
|
270
|
+
this.partyParticles.push({
|
|
271
|
+
el: particle,
|
|
272
|
+
angle: (i / 30) * Math.PI * 2,
|
|
273
|
+
baseRadius: 100 + i * 10
|
|
274
|
+
});
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
const discoContainer = document.getElementById('disco-container');
|
|
279
|
+
if (discoContainer) {
|
|
280
|
+
discoContainer.innerHTML = '';
|
|
281
|
+
this.discoDots = [];
|
|
282
|
+
for (let i = 0; i < 40; i++) {
|
|
283
|
+
const dot = document.createElement('div');
|
|
284
|
+
dot.className = 'disco-dot';
|
|
285
|
+
const baseX = Math.random() * 100;
|
|
286
|
+
const baseY = Math.random() * 100;
|
|
287
|
+
dot.style.left = baseX + '%';
|
|
288
|
+
dot.style.top = baseY + '%';
|
|
289
|
+
discoContainer.appendChild(dot);
|
|
290
|
+
this.discoDots.push({
|
|
291
|
+
el: dot,
|
|
292
|
+
baseX: baseX,
|
|
293
|
+
baseY: baseY,
|
|
294
|
+
angle: Math.random() * Math.PI * 2,
|
|
295
|
+
speed: 0.5 + Math.random() * 1.5,
|
|
296
|
+
orbitRadius: 5 + Math.random() * 15,
|
|
297
|
+
hueOffset: Math.random() * 360,
|
|
298
|
+
lastShadowSize: 0
|
|
299
|
+
});
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
},
|
|
303
|
+
|
|
304
|
+
getBandLevel(band) {
|
|
305
|
+
if (!this.frequencyData) return 0;
|
|
306
|
+
let sum = 0;
|
|
307
|
+
const count = band.end - band.start;
|
|
308
|
+
for (let i = band.start; i < band.end; i++) {
|
|
309
|
+
sum += this.frequencyData[i];
|
|
310
|
+
}
|
|
311
|
+
return (sum / count / 255) * this.audioSensitivity;
|
|
312
|
+
},
|
|
313
|
+
|
|
314
|
+
detectBeat(bassLevel) {
|
|
315
|
+
const now = Date.now();
|
|
316
|
+
this.bassHistory.push(bassLevel);
|
|
317
|
+
if (this.bassHistory.length > 8) this.bassHistory.shift();
|
|
318
|
+
|
|
319
|
+
const bassAvg = this.bassHistory.reduce((a, b) => a + b, 0) / this.bassHistory.length;
|
|
320
|
+
const peakRatio = bassAvg > 0.05 ? (bassLevel / bassAvg) : 1;
|
|
321
|
+
const isAboveAvg = peakRatio > 1.15;
|
|
322
|
+
const isStrongEnough = bassLevel > 0.12;
|
|
323
|
+
const cooldownPassed = (now - this.lastBeatTime) > this.beatCooldown;
|
|
324
|
+
const spike = bassLevel - this.prevBassLevel;
|
|
325
|
+
const isSharpSpike = spike > 0.05 && bassLevel > 0.1;
|
|
326
|
+
|
|
327
|
+
this.prevBassLevel = bassLevel;
|
|
328
|
+
|
|
329
|
+
const isBeat = cooldownPassed && ((isAboveAvg && isStrongEnough) || isSharpSpike);
|
|
330
|
+
if (isBeat) this.lastBeatTime = now;
|
|
331
|
+
return isBeat;
|
|
332
|
+
},
|
|
333
|
+
|
|
334
|
+
startAnimation() {
|
|
335
|
+
if (this.animationId) return;
|
|
336
|
+
this._containersActive = false;
|
|
337
|
+
this.animate();
|
|
338
|
+
},
|
|
339
|
+
|
|
340
|
+
stopAnimation() {
|
|
341
|
+
if (this.animationId) {
|
|
342
|
+
cancelAnimationFrame(this.animationId);
|
|
343
|
+
this.animationId = null;
|
|
344
|
+
}
|
|
345
|
+
this._containersActive = false;
|
|
346
|
+
if (this._dom) {
|
|
347
|
+
if (this._dom.partyContainer) this._dom.partyContainer.classList.remove('active');
|
|
348
|
+
if (this._dom.oscContainer) this._dom.oscContainer.classList.remove('active');
|
|
349
|
+
}
|
|
350
|
+
if (this._vizContainers) {
|
|
351
|
+
this._vizContainers.forEach(el => el.classList.remove('active'));
|
|
352
|
+
}
|
|
353
|
+
},
|
|
354
|
+
|
|
355
|
+
animate() {
|
|
356
|
+
if (!this.enabled) {
|
|
357
|
+
this.stopAnimation();
|
|
358
|
+
return;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
const musicPlayer = window.musicPlayer;
|
|
362
|
+
if (!musicPlayer || !musicPlayer.isPlaying) {
|
|
363
|
+
this.stopAnimation();
|
|
364
|
+
return;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
// Show effects containers (once, not every frame)
|
|
368
|
+
if (!this._containersActive) {
|
|
369
|
+
if (this._dom.partyContainer) this._dom.partyContainer.classList.add('active');
|
|
370
|
+
// Only show square-frame bars when NOT in halo-smoke mode
|
|
371
|
+
const isHaloMode = this._dom.faceBox && this._dom.faceBox.classList.contains('halo-smoke-mode');
|
|
372
|
+
if (this._vizContainers && !isHaloMode) {
|
|
373
|
+
this._vizContainers.forEach(el => el.classList.add('active'));
|
|
374
|
+
}
|
|
375
|
+
this._containersActive = true;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
// Setup analyser if needed
|
|
379
|
+
if (!this.sourceNode1 && this.enabled) {
|
|
380
|
+
this.setupAnalyser();
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
// Get frequency data (single read, shared by all effects)
|
|
384
|
+
if (this.analyser && this.frequencyData) {
|
|
385
|
+
this.analyser.getByteFrequencyData(this.frequencyData);
|
|
386
|
+
if (this.timeDomainData) {
|
|
387
|
+
this.analyser.getByteTimeDomainData(this.timeDomainData);
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
// Extract frequency bands
|
|
392
|
+
const bass = this.getBandLevel(this.BANDS.bass);
|
|
393
|
+
const subBass = this.getBandLevel(this.BANDS.subBass);
|
|
394
|
+
const lowMid = this.getBandLevel(this.BANDS.lowMid);
|
|
395
|
+
const mid = this.getBandLevel(this.BANDS.mid);
|
|
396
|
+
const highMid = this.getBandLevel(this.BANDS.highMid);
|
|
397
|
+
const treble = this.getBandLevel(this.BANDS.treble);
|
|
398
|
+
const fullBass = (bass + subBass) / 2;
|
|
399
|
+
const energy = (bass * 2 + lowMid + mid + highMid + treble) / 6;
|
|
400
|
+
|
|
401
|
+
const time = Date.now() / 1000;
|
|
402
|
+
const isBeat = this.detectBeat(fullBass);
|
|
403
|
+
|
|
404
|
+
const useBass = fullBass;
|
|
405
|
+
const useMid = mid;
|
|
406
|
+
const useEnergy = energy;
|
|
407
|
+
const useHighMid = highMid;
|
|
408
|
+
|
|
409
|
+
// Update all effects
|
|
410
|
+
this.updateCenterGlow(useBass, useEnergy, useMid, isBeat);
|
|
411
|
+
|
|
412
|
+
if (isBeat) {
|
|
413
|
+
this.triggerBeatFlash(useBass);
|
|
414
|
+
this.triggerShake(useBass);
|
|
415
|
+
this.triggerSoundRipple(useBass);
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
this.updateOscilloscope();
|
|
419
|
+
this.updateParticles(useBass, useEnergy, time);
|
|
420
|
+
this.updateDiscoDots(useEnergy, useHighMid, useBass, useMid, time, isBeat);
|
|
421
|
+
this.updateVisualizerBars();
|
|
422
|
+
|
|
423
|
+
this.animationId = requestAnimationFrame(() => this.animate());
|
|
424
|
+
},
|
|
425
|
+
|
|
426
|
+
updateCenterGlow(useBass, useEnergy, useMid, isBeat) {
|
|
427
|
+
const glow = this._dom.centerGlow;
|
|
428
|
+
if (!glow) return;
|
|
429
|
+
|
|
430
|
+
const size = 600 + useBass * 800;
|
|
431
|
+
const hue = 180 + useEnergy * 60 + useMid * 40;
|
|
432
|
+
const saturation = 80 + useBass * 20;
|
|
433
|
+
const lightness = 50;
|
|
434
|
+
const opacity = 0.3 + useEnergy * 0.7;
|
|
435
|
+
|
|
436
|
+
glow.style.width = size + 'px';
|
|
437
|
+
glow.style.height = size + 'px';
|
|
438
|
+
glow.style.background = `radial-gradient(circle,
|
|
439
|
+
hsla(${hue}, ${saturation}%, ${lightness}%, ${opacity * 0.4}) 0%,
|
|
440
|
+
hsla(${hue - 30}, ${saturation}%, ${lightness - 20}%, ${opacity * 0.15}) 40%,
|
|
441
|
+
transparent 70%)`;
|
|
442
|
+
glow.style.opacity = opacity;
|
|
443
|
+
|
|
444
|
+
if (isBeat) {
|
|
445
|
+
glow.style.filter = 'blur(40px)';
|
|
446
|
+
setTimeout(() => glow.style.filter = 'blur(60px)', 100);
|
|
447
|
+
}
|
|
448
|
+
},
|
|
449
|
+
|
|
450
|
+
triggerBeatFlash(useBass) {
|
|
451
|
+
const flash = this._dom.beatFlash;
|
|
452
|
+
if (!flash) return;
|
|
453
|
+
flash.style.opacity = Math.min(useBass * 0.6, 0.4);
|
|
454
|
+
setTimeout(() => flash.style.opacity = 0, 80);
|
|
455
|
+
},
|
|
456
|
+
|
|
457
|
+
triggerShake(useBass) {
|
|
458
|
+
const faceBox = this._dom.faceBox;
|
|
459
|
+
if (!faceBox) return;
|
|
460
|
+
faceBox.classList.add('shake');
|
|
461
|
+
faceBox.style.setProperty('--shake-amount', (useBass * 8) + 'px');
|
|
462
|
+
setTimeout(() => faceBox.classList.remove('shake'), 100);
|
|
463
|
+
},
|
|
464
|
+
|
|
465
|
+
triggerSoundRipple(useBass) {
|
|
466
|
+
if (useBass < 0.15) return;
|
|
467
|
+
if (!this._ripplePool) return;
|
|
468
|
+
|
|
469
|
+
// Reuse pooled ripple element
|
|
470
|
+
const ripple = this._ripplePool[this._rippleIndex];
|
|
471
|
+
this._rippleIndex = (this._rippleIndex + 1) % this.RIPPLE_POOL_SIZE;
|
|
472
|
+
|
|
473
|
+
// Reset animation by removing and re-adding
|
|
474
|
+
ripple.style.display = 'none';
|
|
475
|
+
// Force reflow on single small element (negligible cost)
|
|
476
|
+
ripple.offsetHeight;
|
|
477
|
+
ripple.style.display = '';
|
|
478
|
+
},
|
|
479
|
+
|
|
480
|
+
updateOscilloscope() {
|
|
481
|
+
const ctx = this._dom.oscCtx;
|
|
482
|
+
if (!ctx) return;
|
|
483
|
+
|
|
484
|
+
const oscContainer = this._dom.oscContainer;
|
|
485
|
+
if (oscContainer && !oscContainer.classList.contains('active')) {
|
|
486
|
+
oscContainer.classList.add('active');
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
const w = this._oscW;
|
|
490
|
+
const h = this._oscH;
|
|
491
|
+
if (w === 0 || h === 0) return;
|
|
492
|
+
|
|
493
|
+
ctx.clearRect(0, 0, w, h);
|
|
494
|
+
|
|
495
|
+
// timeDomainData already read in animate() — no duplicate read
|
|
496
|
+
if (!this.analyser || !this.timeDomainData) return;
|
|
497
|
+
|
|
498
|
+
const sampleStep = 16;
|
|
499
|
+
const numSamples = Math.floor(this.timeDomainData.length / sampleStep);
|
|
500
|
+
const sliceWidth = w / numSamples;
|
|
501
|
+
const halfH = h / 2;
|
|
502
|
+
const ampScale = h * 0.35;
|
|
503
|
+
|
|
504
|
+
// Draw glow layer
|
|
505
|
+
ctx.beginPath();
|
|
506
|
+
ctx.strokeStyle = 'rgba(0, 255, 255, 0.25)';
|
|
507
|
+
ctx.lineWidth = 20;
|
|
508
|
+
ctx.lineCap = 'round';
|
|
509
|
+
ctx.lineJoin = 'round';
|
|
510
|
+
|
|
511
|
+
let x = 0;
|
|
512
|
+
for (let i = 0; i < numSamples; i++) {
|
|
513
|
+
const v = this.timeDomainData[i * sampleStep] / 128.0;
|
|
514
|
+
const y = halfH + (v - 1) * ampScale;
|
|
515
|
+
if (i === 0) ctx.moveTo(x, y);
|
|
516
|
+
else ctx.lineTo(x, y);
|
|
517
|
+
x += sliceWidth;
|
|
518
|
+
}
|
|
519
|
+
ctx.stroke();
|
|
520
|
+
|
|
521
|
+
// Draw main bright line
|
|
522
|
+
ctx.beginPath();
|
|
523
|
+
ctx.strokeStyle = '#00ffff';
|
|
524
|
+
ctx.lineWidth = 6;
|
|
525
|
+
x = 0;
|
|
526
|
+
|
|
527
|
+
for (let i = 0; i < numSamples; i++) {
|
|
528
|
+
const v = this.timeDomainData[i * sampleStep] / 128.0;
|
|
529
|
+
const y = halfH + (v - 1) * ampScale;
|
|
530
|
+
if (i === 0) ctx.moveTo(x, y);
|
|
531
|
+
else ctx.lineTo(x, y);
|
|
532
|
+
x += sliceWidth;
|
|
533
|
+
}
|
|
534
|
+
ctx.stroke();
|
|
535
|
+
},
|
|
536
|
+
|
|
537
|
+
updateParticles(useBass, useEnergy, time) {
|
|
538
|
+
const opacity = 0.3 + useEnergy * 0.7;
|
|
539
|
+
const size = 4 + useEnergy * 8;
|
|
540
|
+
|
|
541
|
+
this.partyParticles.forEach((p, i) => {
|
|
542
|
+
const radius = p.baseRadius + useBass * 200;
|
|
543
|
+
const orbitSpeed = 0.3 + (i % 5) * 0.1;
|
|
544
|
+
const px = 50 + Math.cos(time * orbitSpeed + p.angle) * (radius / 10);
|
|
545
|
+
const py = 50 + Math.sin(time * orbitSpeed * 0.7 + p.angle) * (radius / 15);
|
|
546
|
+
|
|
547
|
+
const el = p.el;
|
|
548
|
+
el.style.left = px + '%';
|
|
549
|
+
el.style.top = py + '%';
|
|
550
|
+
el.style.width = size + 'px';
|
|
551
|
+
el.style.height = size + 'px';
|
|
552
|
+
el.style.opacity = opacity;
|
|
553
|
+
});
|
|
554
|
+
},
|
|
555
|
+
|
|
556
|
+
updateDiscoDots(useEnergy, useHighMid, useBass, useMid, time, isBeat) {
|
|
557
|
+
const centerX = 50, centerY = 50;
|
|
558
|
+
const opacity = 0.3 + useEnergy * 0.7;
|
|
559
|
+
const shadowBase = 10 + useBass * 20;
|
|
560
|
+
// Quantize shadow size to reduce repaint (round to nearest 3px)
|
|
561
|
+
const quantizedShadow = Math.round(shadowBase / 3) * 3;
|
|
562
|
+
|
|
563
|
+
this.discoDots.forEach((dot) => {
|
|
564
|
+
const dynamicRadius = dot.orbitRadius * (0.5 + useEnergy * 2);
|
|
565
|
+
const orbitX = Math.cos(time * dot.speed + dot.angle) * dynamicRadius;
|
|
566
|
+
const orbitY = Math.sin(time * dot.speed * 0.8 + dot.angle) * dynamicRadius;
|
|
567
|
+
|
|
568
|
+
const pullStrength = 1 - useEnergy;
|
|
569
|
+
const targetX = dot.baseX + orbitX;
|
|
570
|
+
const targetY = dot.baseY + orbitY;
|
|
571
|
+
const x = targetX + (centerX - targetX) * pullStrength * 0.7;
|
|
572
|
+
const y = targetY + (centerY - targetY) * pullStrength * 0.7;
|
|
573
|
+
|
|
574
|
+
let size = 0.4 + useHighMid * 0.6 + useBass * 0.4;
|
|
575
|
+
if (isBeat) size += 0.5;
|
|
576
|
+
|
|
577
|
+
const hue = (dot.hueOffset + time * 30 + useMid * 60) % 360;
|
|
578
|
+
const hueRound = Math.round(hue);
|
|
579
|
+
|
|
580
|
+
const el = dot.el;
|
|
581
|
+
el.style.left = x + '%';
|
|
582
|
+
el.style.top = y + '%';
|
|
583
|
+
el.style.transform = `scale(${size})`;
|
|
584
|
+
el.style.opacity = opacity;
|
|
585
|
+
el.style.background = `hsl(${hueRound}, 100%, 70%)`;
|
|
586
|
+
|
|
587
|
+
// Only update expensive boxShadow when size changes meaningfully
|
|
588
|
+
if (quantizedShadow !== dot.lastShadowSize) {
|
|
589
|
+
el.style.boxShadow = `0 0 ${quantizedShadow}px hsl(${hueRound}, 100%, 50%)`;
|
|
590
|
+
dot.lastShadowSize = quantizedShadow;
|
|
591
|
+
}
|
|
592
|
+
});
|
|
593
|
+
},
|
|
594
|
+
|
|
595
|
+
updateVisualizerBars() {
|
|
596
|
+
if (!this.frequencyData) return;
|
|
597
|
+
|
|
598
|
+
// Skip bar updates when halo-smoke face is active (bars are hidden via CSS)
|
|
599
|
+
if (this._dom.faceBox && this._dom.faceBox.classList.contains('halo-smoke-mode')) return;
|
|
600
|
+
|
|
601
|
+
// Use cached bar arrays with pre-parsed multipliers
|
|
602
|
+
const numBars = this.NUM_BARS;
|
|
603
|
+
const sensitivity = this.audioSensitivity;
|
|
604
|
+
const freqData = this.frequencyData;
|
|
605
|
+
|
|
606
|
+
if (this._vizBars) {
|
|
607
|
+
for (let i = 0; i < this._vizBars.length; i++) {
|
|
608
|
+
const bar = this._vizBars[i];
|
|
609
|
+
const bandIndex = Math.floor((i % numBars) / numBars * 256);
|
|
610
|
+
const level = freqData[bandIndex] / 255 * sensitivity;
|
|
611
|
+
bar.el.style.height = ((8 + level * 50) * bar.mult) + 'px';
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
if (this._sideBars) {
|
|
616
|
+
for (let i = 0; i < this._sideBars.length; i++) {
|
|
617
|
+
const bar = this._sideBars[i];
|
|
618
|
+
const bandIndex = Math.floor((i % numBars) / numBars * 256);
|
|
619
|
+
const level = freqData[bandIndex] / 255 * sensitivity;
|
|
620
|
+
bar.el.style.width = ((15 + level * 70) * bar.mult) + 'px';
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
},
|
|
624
|
+
|
|
625
|
+
setEnabled(enabled) {
|
|
626
|
+
this.enabled = enabled;
|
|
627
|
+
localStorage.setItem('visualizerEnabled', enabled);
|
|
628
|
+
this.updateToggleUI();
|
|
629
|
+
if (!enabled) this.stopAnimation();
|
|
630
|
+
},
|
|
631
|
+
|
|
632
|
+
setAutoplay(enabled) {
|
|
633
|
+
this.autoplayEnabled = enabled;
|
|
634
|
+
localStorage.setItem('musicAutoplay', enabled);
|
|
635
|
+
this.updateToggleUI();
|
|
636
|
+
}
|
|
637
|
+
};
|