import * as Tone from 'tone'; import { GamepadMapper } from './gamepad-mapper.js'; class DawdlehornApp { constructor() { this.audioInitialized = false; this.gamepadConnected = false; this.gamepadIndex = null; // Audio components this.synth = null; this.filter = null; this.volume = null; // State this.currentFreq = 440; this.currentVolume = 0.5; this.currentFilter = 1000; this.isPlaying = false; // Gamepad state tracking this.gamepadState = { leftStick: { x: 0, y: 0 }, rightStick: { x: 0, y: 0 }, dpadAxis: 0, buttons: new Set() }; // UI elements this.elements = { gamepadStatus: document.getElementById('gamepad-status'), audioStatus: document.getElementById('audio-status'), freqDisplay: document.getElementById('freq-display'), volumeDisplay: document.getElementById('volume-display'), filterDisplay: document.getElementById('filter-display'), leftStickDisplay: document.getElementById('left-stick-display'), rightStickDisplay: document.getElementById('right-stick-display'), dpadDisplay: document.getElementById('dpad-display'), buttonsDisplay: document.getElementById('buttons-display'), calibrationDisplay: document.getElementById('calibration-display'), visualizer: document.getElementById('visualizer') }; // Initialize gamepad mapper this.gamepadMapper = new GamepadMapper({ deadzone: 0.15, debounceTime: 30, enableVibration: true }); this.init(); } async init() { console.log('🎮 Initializing Dawdlehorn...'); // Set up gamepad detection this.setupGamepadDetection(); // Set up audio (will be initialized on first user interaction) this.setupAudioComponents(); // Start gamepad polling this.startGamepadPolling(); // Set up visualizer this.setupVisualizer(); console.log('✅ Dawdlehorn initialized'); } setupGamepadDetection() { // Set up gamepad mapper event listeners this.gamepadMapper.on('connected', (data) => { console.log(`🎮 Gamepad connected: ${data.id} (Type: ${data.type})`); this.gamepadConnected = true; this.gamepadIndex = data.index; this.elements.gamepadStatus.textContent = `Connected: ${data.id} (${data.type})`; this.elements.gamepadStatus.style.color = '#00ff00'; }); this.gamepadMapper.on('disconnected', (data) => { console.log('🎮 Gamepad disconnected'); this.gamepadConnected = false; this.gamepadIndex = null; this.elements.gamepadStatus.textContent = 'Not Connected'; this.elements.gamepadStatus.style.color = '#ff6600'; }); // Handle button events this.gamepadMapper.on('buttondown', (data) => { this.handleButtonPress(data.button, data.value); }); this.gamepadMapper.on('buttonup', (data) => { this.handleButtonRelease(data.button); }); // Handle axis changes this.gamepadMapper.on('axischange', (data) => { this.handleAxisChange(data.axis, data.value, data.rawValue); }); } async setupAudioComponents() { try { // Create audio chain: Synth -> Filter -> Volume -> Destination this.synth = new Tone.Oscillator(440, 'sawtooth'); this.filter = new Tone.Filter(1000, 'lowpass'); this.volume = new Tone.Volume(-20); // Connect the audio chain this.synth.connect(this.filter); this.filter.connect(this.volume); this.volume.toDestination(); this.elements.audioStatus.textContent = 'Ready (Click to start)'; this.elements.audioStatus.style.color = '#ffff00'; // Set up click-to-start audio document.addEventListener('click', this.initializeAudio.bind(this), { once: true }); } catch (error) { console.error('❌ Audio setup failed:', error); this.elements.audioStatus.textContent = 'Setup Failed'; this.elements.audioStatus.style.color = '#ff0000'; } } async initializeAudio() { try { await Tone.start(); console.log('🔊 Audio context started'); this.audioInitialized = true; this.elements.audioStatus.textContent = 'Active'; this.elements.audioStatus.style.color = '#00ff00'; } catch (error) { console.error('❌ Audio initialization failed:', error); this.elements.audioStatus.textContent = 'Failed'; this.elements.audioStatus.style.color = '#ff0000'; } } startGamepadPolling() { // The GamepadMapper handles polling internally, so we just need to update the debug display this.updateDebugDisplay(); } updateDebugDisplay() { const updateLoop = () => { if (this.gamepadConnected) { // Update debug displays with current gamepad state this.elements.leftStickDisplay.textContent = `${this.gamepadState.leftStick.x.toFixed(2)}, ${this.gamepadState.leftStick.y.toFixed(2)}`; this.elements.rightStickDisplay.textContent = `${this.gamepadState.rightStick.x.toFixed(2)}, ${this.gamepadState.rightStick.y.toFixed(2)}`; // Display DPAD direction in a user-friendly format let dpadText; if (this.gamepadState.dpadAxis === null) { dpadText = 'Neutral'; } else { const directionMap = { 0: 'Right (0°)', 45: 'Up-Right (45°)', 90: 'Up (90°)', 135: 'Up-Left (135°)', 180: 'Left (180°)', 225: 'Down-Left (225°)', 270: 'Down (270°)', 315: 'Down-Right (315°)' }; dpadText = directionMap[this.gamepadState.dpadAxis] || `${this.gamepadState.dpadAxis}°`; } this.elements.dpadDisplay.textContent = dpadText; // Update button display - show ALL pressed buttons dynamically let buttonDisplay = ''; // Convert Set to Array and sort for consistent display order const pressedButtons = Array.from(this.gamepadState.buttons).sort(); pressedButtons.forEach((button, index) => { if (buttonDisplay) buttonDisplay += ' '; buttonDisplay += `[${button}]`; }); this.elements.buttonsDisplay.textContent = buttonDisplay || 'None pressed'; // Update calibration display this.updateCalibrationDisplay(); } requestAnimationFrame(updateLoop); }; updateLoop(); } updateCalibrationDisplay() { if (!this.gamepadConnected || this.gamepadIndex === null) { this.elements.calibrationDisplay.textContent = 'Move sticks to calibrate...'; return; } const calibrationData = this.gamepadMapper.getCalibrationData(this.gamepadIndex); if (Object.keys(calibrationData).length === 0) { this.elements.calibrationDisplay.textContent = 'Move sticks to calibrate...'; return; } let calibrationText = ''; const axisOrder = ['LEFT_STICK_X', 'LEFT_STICK_Y', 'RIGHT_STICK_X', 'RIGHT_STICK_Y']; axisOrder.forEach(axisName => { if (calibrationData[axisName]) { const { neutral, negativeMin, positiveMax, dynamicDeadzone } = calibrationData[axisName]; const shortName = axisName.replace('_STICK_', '').replace('LEFT', 'L').replace('RIGHT', 'R'); calibrationText += `${shortName}: ${negativeMin.toFixed(3)} ← ${neutral.toFixed(3)} → ${positiveMax.toFixed(3)} (DZ:${dynamicDeadzone.toFixed(3)})\n`; } }); this.elements.calibrationDisplay.textContent = calibrationText.trim() || 'Move sticks to calibrate...'; } handleButtonPress(button, value) { try { console.log(`🎮 Button pressed: ${button} (${value})`); this.gamepadState.buttons.add(button); if (!this.audioInitialized) return; // Map face buttons to notes with proper debouncing const noteMap = { 'A': 261.63, // C4 'B': 293.66, // D4 'X': 329.63, // E4 'Y': 349.23 // F4 }; if (noteMap[button]) { if (!this.isPlaying) { this.synth.start(); this.isPlaying = true; } this.synth.frequency.setValueAtTime(noteMap[button], Tone.now()); // Add vibration feedback if available if (this.gamepadIndex !== null) { this.gamepadMapper.vibrate(this.gamepadIndex, 100, 0.3, 0.1); } } } catch (error) { console.error('❌ Button press error:', error); } } handleButtonRelease(button) { try { console.log(`🎮 Button released: ${button}`); this.gamepadState.buttons.delete(button); if (!this.audioInitialized) return; // Stop audio if no face buttons are pressed const faceButtons = ['A', 'B', 'X', 'Y']; const anyFaceButtonPressed = faceButtons.some(btn => this.gamepadState.buttons.has(btn)); if (!anyFaceButtonPressed && this.isPlaying) { this.synth.stop(); this.isPlaying = false; } } catch (error) { console.error('❌ Button release error:', error); } } handleAxisChange(axis, value, rawValue) { try { // Debug logging for stick issues (can be removed in production) // if (axis.includes('STICK')) { // console.log(`🕹️ ${axis}: processed=${value.toFixed(3)}, raw=${rawValue.toFixed(3)}`); // } // Update gamepad state switch (axis) { case 'LEFT_STICK_X': this.gamepadState.leftStick.x = value; this.updateFrequency(); break; case 'LEFT_STICK_Y': this.gamepadState.leftStick.y = value; break; case 'RIGHT_STICK_X': this.gamepadState.rightStick.x = value; break; case 'RIGHT_STICK_Y': this.gamepadState.rightStick.y = value; this.updateFilter(); break; case 'DPAD_AXIS': this.gamepadState.dpadAxis = value; break; } } catch (error) { console.error('❌ Axis change error:', error); } } updateFrequency() { if (!this.audioInitialized) return; // Left stick X-axis controls frequency (200Hz - 2000Hz) const newFreq = 440 + (this.gamepadState.leftStick.x * 560); // 440Hz ± 560Hz if (Math.abs(newFreq - this.currentFreq) > 5) { this.currentFreq = newFreq; this.synth.frequency.setValueAtTime(this.currentFreq, Tone.now()); this.elements.freqDisplay.textContent = `${Math.round(this.currentFreq)} Hz`; } } updateFilter() { if (!this.audioInitialized) return; // Right stick Y-axis controls filter frequency (100Hz - 5000Hz) const newFilter = 1000 + (this.gamepadState.rightStick.y * -2000); // Inverted Y, 1000Hz ± 2000Hz if (Math.abs(newFilter - this.currentFilter) > 50) { this.currentFilter = Math.max(100, Math.min(5000, newFilter)); this.filter.frequency.setValueAtTime(this.currentFilter, Tone.now()); this.elements.filterDisplay.textContent = `${Math.round(this.currentFilter)} Hz`; } } updateVolume() { if (!this.audioInitialized) return; // Simple volume control - could be enhanced later if needed // For now, just keep the current volume logic without trigger dependency const dbVolume = -40 + (this.currentVolume * 40); // -40dB to 0dB this.volume.volume.setValueAtTime(dbVolume, Tone.now()); this.elements.volumeDisplay.textContent = `${Math.round(this.currentVolume * 100)}%`; } setupVisualizer() { const canvas = this.elements.visualizer; const ctx = canvas.getContext('2d'); // Set canvas size const resizeCanvas = () => { canvas.width = canvas.offsetWidth; canvas.height = canvas.offsetHeight; }; resizeCanvas(); window.addEventListener('resize', resizeCanvas); // Simple visualizer animation const animate = () => { ctx.fillStyle = '#000'; ctx.fillRect(0, 0, canvas.width, canvas.height); if (this.isPlaying) { const time = Date.now() * 0.01; const centerY = canvas.height / 2; ctx.strokeStyle = '#00ff00'; ctx.lineWidth = 2; ctx.beginPath(); for (let x = 0; x < canvas.width; x += 2) { const freq = this.currentFreq / 1000; const y = centerY + Math.sin((x * 0.02) + time * freq) * (this.currentVolume * 30); if (x === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y); } ctx.stroke(); } requestAnimationFrame(animate); }; animate(); } } // Initialize the app when the page loads document.addEventListener('DOMContentLoaded', () => { new DawdlehornApp(); });