| 1 | // js/app.js |
| 2 | import { AudioEngine } from './audio-engine.js'; |
| 3 | import { GestureDetector } from './gesture-detector.js'; |
| 4 | |
| 5 | class GestureDSPApp { |
| 6 | constructor() { |
| 7 | this.audioEngine = new AudioEngine(); |
| 8 | this.gestureDetector = new GestureDetector(); |
| 9 | |
| 10 | this.effectHistory = []; |
| 11 | this.lastCueTime = 0; |
| 12 | this.cueActive = false; |
| 13 | |
| 14 | this.setupUI(); |
| 15 | this.setupEffectZones(); |
| 16 | this.setupGestureCallbacks(); |
| 17 | } |
| 18 | |
| 19 | async loadAudioFileList() { |
| 20 | try { |
| 21 | // First try the API endpoint if available |
| 22 | const response = await fetch('audio/list.php'); |
| 23 | if (response.ok && response.headers.get('content-type')?.includes('application/json')) { |
| 24 | const files = await response.json(); |
| 25 | console.log('Audio files from API:', files); |
| 26 | this.populateAudioSelect(files); |
| 27 | return; |
| 28 | } |
| 29 | } catch (err) { |
| 30 | console.log('API endpoint error:', err); |
| 31 | } |
| 32 | |
| 33 | try { |
| 34 | // Fallback to parsing directory listing |
| 35 | const response = await fetch('audio/'); |
| 36 | const text = await response.text(); |
| 37 | console.log('Directory listing response:', text.substring(0, 200)); |
| 38 | |
| 39 | // Parse the directory listing |
| 40 | const files = this.parseDirectoryListing(text); |
| 41 | console.log('Parsed audio files:', files); |
| 42 | |
| 43 | if (files.length > 0) { |
| 44 | this.populateAudioSelect(files); |
| 45 | } else { |
| 46 | console.log('No audio files found, using defaults'); |
| 47 | this.addDefaultAudioFiles(); |
| 48 | } |
| 49 | } catch (err) { |
| 50 | console.error('Could not load audio file list:', err); |
| 51 | this.addDefaultAudioFiles(); |
| 52 | } |
| 53 | } |
| 54 | |
| 55 | populateAudioSelect(files) { |
| 56 | const select = document.getElementById('audioSelect'); |
| 57 | files.forEach(filename => { |
| 58 | if (filename.toLowerCase().endsWith('.wav')) { |
| 59 | const option = document.createElement('option'); |
| 60 | option.value = filename; |
| 61 | option.textContent = this.formatFilename(filename); |
| 62 | select.appendChild(option); |
| 63 | } |
| 64 | }); |
| 65 | } |
| 66 | |
| 67 | formatFilename(filename) { |
| 68 | // Remove .wav extension and prettify |
| 69 | return filename |
| 70 | .replace(/\.wav$/i, '') |
| 71 | .replace(/[_-]/g, ' ') |
| 72 | .replace(/\b\w/g, l => l.toUpperCase()); |
| 73 | } |
| 74 | |
| 75 | parseDirectoryListing(html) { |
| 76 | const files = []; |
| 77 | |
| 78 | // Try to parse Apache/Nginx directory listing |
| 79 | const linkRegex = /<a\s+href="([^"]+\.wav)"[^>]*>/gi; |
| 80 | let match; |
| 81 | while ((match = linkRegex.exec(html)) !== null) { |
| 82 | const filename = match[1]; |
| 83 | if (!filename.startsWith('/') && !filename.startsWith('..')) { |
| 84 | files.push(filename); |
| 85 | } |
| 86 | } |
| 87 | |
| 88 | // If no files found, try a different pattern (JSON response) |
| 89 | if (files.length === 0) { |
| 90 | try { |
| 91 | const json = JSON.parse(html); |
| 92 | if (Array.isArray(json)) { |
| 93 | return json.filter(f => f.endsWith('.wav')); |
| 94 | } |
| 95 | } catch (e) { |
| 96 | // Not JSON, ignore |
| 97 | } |
| 98 | } |
| 99 | |
| 100 | return files.sort(); |
| 101 | } |
| 102 | |
| 103 | addDefaultAudioFiles() { |
| 104 | // Fallback list of common demo files |
| 105 | const defaultFiles = [ |
| 106 | 'drums.wav', |
| 107 | 'synth.wav', |
| 108 | 'vocals.wav', |
| 109 | 'guitar.wav', |
| 110 | 'piano.wav' |
| 111 | ]; |
| 112 | |
| 113 | const select = document.getElementById('audioSelect'); |
| 114 | defaultFiles.forEach(filename => { |
| 115 | const option = document.createElement('option'); |
| 116 | option.value = filename; |
| 117 | option.textContent = filename.replace(/\.wav$/i, ''); |
| 118 | select.appendChild(option); |
| 119 | }); |
| 120 | } |
| 121 | |
| 122 | async loadAudioFromServer(filename) { |
| 123 | try { |
| 124 | this.updateStatus(`Loading ${filename}...`); |
| 125 | const response = await fetch(`audio/${filename}`); |
| 126 | |
| 127 | if (!response.ok) { |
| 128 | throw new Error(`HTTP error! status: ${response.status}`); |
| 129 | } |
| 130 | |
| 131 | const arrayBuffer = await response.arrayBuffer(); |
| 132 | await this.audioEngine.loadArrayBuffer(arrayBuffer); |
| 133 | this.updateStatus(`Loaded: ${filename}`); |
| 134 | } catch (err) { |
| 135 | console.error('Error loading audio file:', err); |
| 136 | this.updateStatus(`Failed to load ${filename}`); |
| 137 | } |
| 138 | } |
| 139 | |
| 140 | async setupUI() { |
| 141 | // Load available audio files |
| 142 | await this.loadAudioFileList(); |
| 143 | |
| 144 | // Camera control |
| 145 | document.getElementById('startCamera').addEventListener('click', () => this.startCamera()); |
| 146 | |
| 147 | // Microphone control |
| 148 | document.getElementById('microphoneToggle').addEventListener('click', () => this.toggleMicrophone()); |
| 149 | |
| 150 | // Audio selection |
| 151 | document.getElementById('audioSelect').addEventListener('change', async (e) => { |
| 152 | const value = e.target.value; |
| 153 | if (!value) return; |
| 154 | |
| 155 | if (value === '__demo__') { |
| 156 | await this.audioEngine.loadDemoAudio(); |
| 157 | this.updateStatus('Demo audio loaded'); |
| 158 | } else { |
| 159 | await this.loadAudioFromServer(value); |
| 160 | } |
| 161 | document.getElementById('playPause').disabled = false; |
| 162 | }); |
| 163 | |
| 164 | // Audio upload |
| 165 | document.getElementById('loadFile').addEventListener('click', () => { |
| 166 | document.getElementById('fileInput').click(); |
| 167 | }); |
| 168 | |
| 169 | document.getElementById('fileInput').addEventListener('change', async (e) => { |
| 170 | const file = e.target.files[0]; |
| 171 | if (file) { |
| 172 | await this.audioEngine.loadFile(file); |
| 173 | this.updateStatus(`Loaded: ${file.name}`); |
| 174 | document.getElementById('playPause').disabled = false; |
| 175 | |
| 176 | // Reset select to show custom file loaded |
| 177 | document.getElementById('audioSelect').value = ''; |
| 178 | } |
| 179 | }); |
| 180 | |
| 181 | document.getElementById('playPause').addEventListener('click', () => { |
| 182 | this.togglePlayback(); |
| 183 | }); |
| 184 | |
| 185 | // IR controls |
| 186 | document.getElementById('irSelect').addEventListener('change', (e) => { |
| 187 | this.audioEngine.loadPresetIR(e.target.value); |
| 188 | this.updateStatus(`Loaded ${e.target.value} IR`); |
| 189 | }); |
| 190 | |
| 191 | document.getElementById('loadIR').addEventListener('click', () => { |
| 192 | document.getElementById('irInput').click(); |
| 193 | }); |
| 194 | |
| 195 | document.getElementById('irInput').addEventListener('change', async (e) => { |
| 196 | const file = e.target.files[0]; |
| 197 | if (file) { |
| 198 | await this.audioEngine.loadIRFile(file); |
| 199 | this.updateStatus(`Loaded IR: ${file.name}`); |
| 200 | } |
| 201 | }); |
| 202 | } |
| 203 | |
| 204 | setupEffectZones() { |
| 205 | const zonesContainer = document.getElementById('effectZones'); |
| 206 | zonesContainer.innerHTML = ''; |
| 207 | |
| 208 | this.audioEngine.effectNames.forEach((name, index) => { |
| 209 | const zone = document.createElement('div'); |
| 210 | zone.className = 'zone'; |
| 211 | zone.textContent = name.replace('_', ' '); |
| 212 | zone.id = `zone-${index}`; |
| 213 | zonesContainer.appendChild(zone); |
| 214 | }); |
| 215 | } |
| 216 | |
| 217 | setupGestureCallbacks() { |
| 218 | this.gestureDetector.onHandsDetected = (yellowHand, greenHand) => { |
| 219 | // Yellow hand for horizontal effect selection |
| 220 | if (yellowHand) { |
| 221 | this.handleEffectSelection(yellowHand); |
| 222 | } |
| 223 | |
| 224 | // Green hand for vertical parameter control |
| 225 | if (greenHand) { |
| 226 | this.handleParameterControl(greenHand); |
| 227 | } else { |
| 228 | // Slowly return to center when no hand detected |
| 229 | if (this.audioEngine.audioContext && this.audioEngine.effects) { |
| 230 | const currentParam = this.audioEngine.currentParam; |
| 231 | this.audioEngine.updateEffectParameter(currentParam * 0.95 + 0.5 * 0.05); |
| 232 | } |
| 233 | this.updateParameterDisplay(); |
| 234 | } |
| 235 | |
| 236 | // Update hand status with confidence |
| 237 | const status = []; |
| 238 | if (yellowHand) status.push(`Yellow: ${Math.round(yellowHand.confidence * 100)}%`); |
| 239 | if (greenHand) status.push(`Green: ${Math.round(greenHand.confidence * 100)}%`); |
| 240 | document.getElementById('handStatus').textContent = status.length > 0 ? status.join(', ') : 'No'; |
| 241 | }; |
| 242 | } |
| 243 | |
| 244 | handleEffectSelection(handBox) { |
| 245 | const centerX = handBox.x + handBox.width / 2; |
| 246 | const canvasWidth = this.gestureDetector.canvas.width; |
| 247 | const zoneWidth = canvasWidth / this.audioEngine.effectNames.length; |
| 248 | const effectIndex = Math.floor(centerX / zoneWidth); |
| 249 | const clampedIndex = Math.max(0, Math.min(effectIndex, this.audioEngine.effectNames.length - 1)); |
| 250 | |
| 251 | // Update effect history for cue detection |
| 252 | this.effectHistory.push(clampedIndex); |
| 253 | if (this.effectHistory.length > 10) { |
| 254 | this.effectHistory.shift(); |
| 255 | } |
| 256 | |
| 257 | // Check for cue trigger |
| 258 | if (this.effectHistory.length === 10 && |
| 259 | this.effectHistory.every(i => i === clampedIndex) && |
| 260 | Date.now() - this.lastCueTime > 2000) { |
| 261 | this.triggerCue(); |
| 262 | this.lastCueTime = Date.now(); |
| 263 | } |
| 264 | |
| 265 | // Switch effect |
| 266 | this.audioEngine.switchToEffect(clampedIndex); |
| 267 | |
| 268 | // Update UI |
| 269 | document.querySelectorAll('.zone').forEach((zone, i) => { |
| 270 | zone.classList.toggle('active', i === clampedIndex); |
| 271 | }); |
| 272 | |
| 273 | document.getElementById('currentEffect').textContent = |
| 274 | this.audioEngine.effectNames[clampedIndex].replace('_', ' '); |
| 275 | } |
| 276 | |
| 277 | handleParameterControl(handBox) { |
| 278 | const centerY = handBox.y + handBox.height / 2; |
| 279 | const canvasHeight = this.gestureDetector.canvas.height; |
| 280 | const paramMinRatio = 0.2; |
| 281 | const paramMaxRatio = 0.8; |
| 282 | const minY = paramMinRatio * canvasHeight; |
| 283 | const maxY = paramMaxRatio * canvasHeight; |
| 284 | |
| 285 | let param; |
| 286 | if (centerY <= minY) { |
| 287 | param = 1.0; |
| 288 | } else if (centerY >= maxY) { |
| 289 | param = 0.0; |
| 290 | } else { |
| 291 | param = 1.0 - (centerY - minY) / (maxY - minY); |
| 292 | } |
| 293 | |
| 294 | // Only update if audio engine is initialized |
| 295 | if (this.audioEngine.audioContext) { |
| 296 | this.audioEngine.updateEffectParameter(param); |
| 297 | } |
| 298 | this.updateParameterDisplay(); |
| 299 | } |
| 300 | |
| 301 | updateParameterDisplay() { |
| 302 | // Safely get parameters with defaults |
| 303 | const param = this.audioEngine.currentParam || 0.5; |
| 304 | const rawParam = this.audioEngine.rawParam || 0.5; |
| 305 | |
| 306 | document.getElementById('paramValue').textContent = param.toFixed(2); |
| 307 | document.getElementById('rawParamValue').textContent = rawParam.toFixed(2); |
| 308 | document.getElementById('paramFill').style.height = `${param * 100}%`; |
| 309 | } |
| 310 | |
| 311 | triggerCue() { |
| 312 | this.cueActive = true; |
| 313 | const indicator = document.getElementById('cueIndicator'); |
| 314 | indicator.classList.add('active'); |
| 315 | |
| 316 | setTimeout(() => { |
| 317 | indicator.classList.remove('active'); |
| 318 | this.cueActive = false; |
| 319 | }, 1000); |
| 320 | } |
| 321 | |
| 322 | async startCamera() { |
| 323 | try { |
| 324 | // Initialize audio engine first if not already done |
| 325 | await this.audioEngine.init(); |
| 326 | |
| 327 | await this.gestureDetector.start(); |
| 328 | this.updateStatus('Camera active - wear colored gloves!'); |
| 329 | document.getElementById('startCamera').disabled = true; |
| 330 | document.getElementById('microphoneToggle').disabled = false; |
| 331 | } catch (err) { |
| 332 | console.error('Error starting camera:', err); |
| 333 | this.updateStatus('Camera access denied'); |
| 334 | } |
| 335 | } |
| 336 | |
| 337 | async toggleMicrophone() { |
| 338 | const micBtn = document.getElementById('microphoneToggle'); |
| 339 | |
| 340 | try { |
| 341 | if (this.audioEngine.isMicrophoneActive) { |
| 342 | this.audioEngine.stopMicrophone(); |
| 343 | micBtn.textContent = 'Start Microphone'; |
| 344 | micBtn.className = 'btn btn-warning'; |
| 345 | this.updateStatus('Microphone stopped'); |
| 346 | } else { |
| 347 | await this.audioEngine.startMicrophone(); |
| 348 | micBtn.textContent = 'Stop Microphone'; |
| 349 | micBtn.className = 'btn btn-danger'; |
| 350 | this.updateStatus('Live microphone active! Use gestures to control effects.'); |
| 351 | } |
| 352 | } catch (err) { |
| 353 | console.error('Microphone error:', err); |
| 354 | if (err.name === 'NotAllowedError') { |
| 355 | this.updateStatus('Microphone permission denied. Please allow microphone access.'); |
| 356 | } else if (err.name === 'NotFoundError') { |
| 357 | this.updateStatus('No microphone found. Please connect a microphone.'); |
| 358 | } else { |
| 359 | this.updateStatus('Microphone error: ' + err.message); |
| 360 | } |
| 361 | } |
| 362 | } |
| 363 | |
| 364 | togglePlayback() { |
| 365 | if (this.audioEngine.isPlaying) { |
| 366 | this.audioEngine.stop(); |
| 367 | document.getElementById('playPause').textContent = 'Play'; |
| 368 | } else { |
| 369 | this.audioEngine.play(); |
| 370 | document.getElementById('playPause').textContent = 'Stop'; |
| 371 | } |
| 372 | } |
| 373 | |
| 374 | updateStatus(message) { |
| 375 | document.getElementById('status').textContent = message; |
| 376 | } |
| 377 | } |
| 378 | |
| 379 | // Initialize the app when DOM is ready |
| 380 | document.addEventListener('DOMContentLoaded', () => { |
| 381 | window.app = new GestureDSPApp(); |
| 382 | }); |