JavaScript · 14053 bytes Raw Blame History
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 });