1
0

4 Commity 8e0506456e ... dbfc159bef

Autor SHA1 Správa Dátum
  Billie Hilton dbfc159bef map DPAD correctly 4 mesiacov pred
  Billie Hilton dc024d15f8 buttons mapped correctly 4 mesiacov pred
  Billie Hilton cca080c91c docs: Add development server instructions to README and memory bank 4 mesiacov pred
  Billie Hilton b9f75eadf9 refactor gamepad code 4 mesiacov pred
6 zmenil súbory, kde vykonal 942 pridanie a 136 odobranie
  1. 130 0
      GAMEPAD_MAPPING.md
  2. 34 1
      README.md
  3. 3 3
      index.html
  4. 96 36
      memory-bank/current.md
  5. 496 0
      src/gamepad-mapper.js
  6. 183 96
      src/index.js

+ 130 - 0
GAMEPAD_MAPPING.md

@@ -0,0 +1,130 @@
+# GamepadMapper - Advanced Gamepad Input Handling
+
+## Overview
+
+The GamepadMapper class provides a comprehensive solution to address the shortcomings of the vanilla Web Gamepad API, including:
+
+- **Stick Drift Compensation**: Configurable deadzones with proper scaling
+- **Button Debouncing**: Prevents multiple rapid button press events
+- **Controller Mapping**: Standardized button/axis names across different controller types
+- **Event-Driven API**: Replaces inefficient polling with proper event handling
+- **Analog Trigger Support**: Handles controllers that map triggers to buttons vs axes
+- **Vibration Feedback**: Haptic feedback support where available
+
+## Supported Controllers
+
+- **Xbox Controllers** (Xbox One, Xbox Series X/S, Xbox 360)
+- **PlayStation Controllers** (DualShock 4, DualSense)
+- **Nintendo Switch Pro Controller**
+- **Generic controllers** using standard mapping
+
+## Key Features
+
+### Deadzone Handling
+```javascript
+// Configurable deadzone (default: 0.1)
+const mapper = new GamepadMapper({ deadzone: 0.15 });
+
+// Values below deadzone threshold return 0
+// Values above are scaled to maintain full 0-1 range
+```
+
+### Button Debouncing
+```javascript
+// Configurable debounce time (default: 50ms)
+const mapper = new GamepadMapper({ debounceTime: 30 });
+
+// Prevents multiple rapid button events from hardware bounce
+```
+
+### Event-Driven API
+```javascript
+// Listen for button events
+mapper.on('buttondown', (data) => {
+    console.log(`Button ${data.button} pressed on gamepad ${data.gamepadIndex}`);
+});
+
+mapper.on('buttonup', (data) => {
+    console.log(`Button ${data.button} released on gamepad ${data.gamepadIndex}`);
+});
+
+// Listen for analog stick/trigger changes
+mapper.on('axischange', (data) => {
+    console.log(`Axis ${data.axis}: ${data.value} (raw: ${data.rawValue})`);
+});
+```
+
+### Standardized Button Names
+
+| Xbox | PlayStation | Nintendo | Standard Name |
+| ---- | ----------- | -------- | ------------- |
+| A    | X (Cross)   | B        | A             |
+| B    | Circle      | A        | B             |
+| X    | Square      | Y        | X             |
+| Y    | Triangle    | X        | Y             |
+| LB   | L1          | L        | LB            |
+| RB   | R1          | R        | RB            |
+| LT   | L2          | ZL       | LT            |
+| RT   | R2          | ZR       | RT            |
+
+### Standardized Axis Names
+
+- `LEFT_STICK_X` / `LEFT_STICK_Y`
+- `RIGHT_STICK_X` / `RIGHT_STICK_Y`
+- `LEFT_TRIGGER` / `RIGHT_TRIGGER` (for analog triggers)
+
+## Usage Example
+
+```javascript
+import { GamepadMapper } from './gamepad-mapper.js';
+
+const gamepadMapper = new GamepadMapper({
+    deadzone: 0.15,        // 15% deadzone
+    debounceTime: 30,      // 30ms debounce
+    enableVibration: true  // Enable haptic feedback
+});
+
+// Handle button presses
+gamepadMapper.on('buttondown', (data) => {
+    if (data.button === 'A') {
+        // Play note, trigger action, etc.
+        gamepadMapper.vibrate(data.gamepadIndex, 100, 0.3, 0.1);
+    }
+});
+
+// Handle analog input
+gamepadMapper.on('axischange', (data) => {
+    if (data.axis === 'LEFT_STICK_X') {
+        // Update frequency, position, etc.
+        updateFrequency(data.value);
+    }
+});
+```
+
+## Configuration Options
+
+```javascript
+const options = {
+    deadzone: 0.1,          // Stick deadzone (0-1)
+    debounceTime: 50,       // Button debounce time (ms)
+    pollRate: 60,           // Polling rate (fps)
+    enableVibration: false  // Enable vibration support
+};
+```
+
+## Benefits Over Vanilla API
+
+1. **No Stick Drift**: Automatic deadzone compensation eliminates unwanted input
+2. **Clean Button Events**: Debouncing prevents multiple rapid button presses
+3. **Consistent Mapping**: Same button names work across all controller types
+4. **Better Performance**: Event-driven approach reduces CPU usage
+5. **Enhanced Features**: Vibration support and analog trigger detection
+6. **Easier Integration**: Simple event-based API vs complex polling logic
+
+## Implementation Notes
+
+- Automatically detects controller type on connection
+- Handles analog triggers mapped to either buttons or axes
+- Scales deadzone-compensated values to maintain full range
+- Cleans up resources properly on disconnect
+- Supports multiple simultaneous controllers

+ 34 - 1
README.md

@@ -1,3 +1,36 @@
 # dawdlehorn
 
-The dawdle horn is a Digital Audio Workstation / Groovebox / musical instrument that's controlled via a gamepad. Probably uses the web Gamepad API, along with Web MIDI and Web Audio APIs.
+The dawdle horn is a Digital Audio Workstation / Groovebox / musical instrument that's controlled via a gamepad. Uses the web Gamepad API, along with Web MIDI and Web Audio APIs.
+
+## Development
+
+**Important: Always use the development server, not direct file opening**
+
+To run the development server:
+```bash
+npm run dev
+```
+
+This starts the Vite development server which is required for:
+- ES6 module imports to work properly
+- Hot reloading during development
+- Proper CORS handling for web APIs
+- Audio context initialization (requires user interaction)
+
+**Do NOT use `open index.html`** - this will not work properly due to CORS restrictions and missing module resolution.
+
+## Getting Started
+
+1. Install dependencies:
+   ```bash
+   npm install
+   ```
+
+2. Start the development server:
+   ```bash
+   npm run dev
+   ```
+
+3. Open your browser to the URL shown in the terminal (typically `http://localhost:5173`)
+
+4. Connect a gamepad and start making music!

+ 3 - 3
index.html

@@ -132,8 +132,8 @@
                     <div id="right-stick-display">0.00, 0.00</div>
                 </div>
                 <div class="control-group">
-                    <label class="control-label">Triggers</label>
-                    <div id="triggers-display">L: 0.00 R: 0.00</div>
+                    <label class="control-label">DPAD (Axis)</label>
+                    <div id="dpad-display">0.00</div>
                 </div>
                 <div class="control-group">
                     <label class="control-label">Buttons</label>
@@ -145,7 +145,7 @@
         <canvas id="visualizer" class="visualizer"></canvas>
         
         <div class="instructions">
-            Connect a gamepad and press any button to start • Left stick: Frequency • Right stick: Filter • Triggers: Volume • Face buttons: Notes
+            Connect a gamepad and press any button to start • Left stick: Frequency • Right stick: Filter • Face buttons: Notes
         </div>
     </div>
     

+ 96 - 36
memory-bank/current.md

@@ -1,26 +1,38 @@
 # Dawdlehorn - Current State
 
-## Current Task: Project Initialization & Technical Planning
-Building a web-based Digital Audio Workstation controlled via gamepad with real-time audio processing capabilities.
+## Current Task: DPAD Axis Mapping and Dead Zone Immunity (COMPLETED)
+Implemented proper DPAD axis handling for Nintendo controllers, making the DPAD axis immune to dead zone filtering and mapping raw values to degrees for better usability.
 
 ## Current Task Checklist
-- [x] Read existing project files to understand requirements
-- [x] Initialize memory bank structure
-- [x] Draft technical plan with libraries and architecture
-- [x] Document current state and next steps
-- [x] Set up project structure and package.json
-- [x] Install Tone.js and set up basic audio context
-- [x] Implement gamepad detection and input polling
-- [x] Build simple synthesizer with gamepad control
-- [x] Add visual debugging for input latency
-- [ ] Create basic Web Components for UI controls
-- [ ] Add pattern sequencer functionality
+- [x] Read current project state from memory bank
+- [x] Analyze DPAD axis implementation and dead zone filtering
+- [x] Implement DPAD axis immunity to dead zone filtering
+- [x] Map DPAD values according to specification
+- [x] Test the implementation
+- [x] Update DPAD mapping with correct raw values and degrees
+- [x] Update display to show DPAD directions in user-friendly format
+- [x] Update memory bank with changes
+
+## Previous Task: Custom Gamepad Mapping Implementation (COMPLETED)
+- [x] Research gamepad API limitations and solutions
+- [x] Design GamepadMapper class architecture
+- [x] Implement controller type detection (Xbox/PlayStation/Nintendo)
+- [x] Add configurable deadzone compensation for stick drift
+- [x] Implement button debouncing with timeout-based approach
+- [x] Create event-driven API to replace polling
+- [x] Add analog trigger handling for different controller types
+- [x] Integrate vibration/haptic feedback support
+- [x] Replace vanilla gamepad handling in main app
+- [x] Test integration with synthesizer controls
+- [x] Verify event-driven architecture works correctly
+- [x] Complete implementation and documentation
 
 ## Project Status
-- **Phase**: Core functionality implemented and testing
-- **Repository**: Working gamepad-controlled synthesizer with debug interface
-- **Next Priority**: Optimize latency and add sequencer functionality
-- **Current Issue**: Some latency between button press and audio response
+- **Phase**: Gamepad debug interface improvements
+- **Repository**: Advanced gamepad-controlled synthesizer with comprehensive input handling
+- **Current Controller**: Hori third-party Nintendo Controller ("0f0d-00f6 Lic Pro Controller")
+- **Next Priority**: Testing trigger display fix and continued button mapping accuracy
+- **Recent Achievement**: Fixed triggers display to show proper Nintendo controller button names (ZL/ZR)
 
 ## Key Decisions Needed
 1. Audio synthesis approach (Web Audio API patterns)
@@ -32,9 +44,16 @@ Building a web-based Digital Audio Workstation controlled via gamepad with real-
 ## Current Understanding
 - Target: Gamepad-controlled groovebox/DAW
 - Core APIs: Web Gamepad, Web Audio, Web MIDI
-- Environment: Browser-based, no server required
+- Environment: Browser-based with Vite dev server
 - Focus: Real-time performance and low latency
 
+## Development Server Requirements
+**CRITICAL: Always use `npm run dev` to start the development server**
+- **NEVER use `open index.html`** - this will fail due to CORS and module resolution issues
+- Vite dev server is required for ES6 modules, hot reloading, and proper web API access
+- Development URL: typically `http://localhost:5173`
+- Audio context requires user interaction, which the dev server handles properly
+
 ## Technical Plan Summary
 **Selected Stack:**
 - **Audio**: Tone.js + Audio Worklets for synthesis and effects
@@ -45,24 +64,65 @@ Building a web-based Digital Audio Workstation controlled via gamepad with real-
 **Architecture**: Event-driven, audio-first design with separate processing threads
 
 ## Current Implementation Status
-**Working Features:**
-- Gamepad detection and connection status
-- Real-time input polling with visual debug feedback
-- Synthesizer with frequency, filter, and volume control
-- Face button note triggering (C4, D4, E4, F4)
-- Audio visualization
-- Click-to-start audio context
+**GamepadMapper Features Implemented:**
+- Multi-controller support (Xbox, PlayStation, Nintendo Switch Pro)
+- Configurable deadzone compensation (default 0.15)
+- Button debouncing (30ms default) to prevent multiple rapid presses
+- Event-driven architecture replacing inefficient polling
+- Analog trigger detection and proper axis mapping
+- Vibration feedback support
+- Standardized button/axis naming across controller types
+
+**Application Integration:**
+- Event-driven gamepad handling with buttondown/buttonup/axischange events
+- Real-time synthesizer control via gamepad input
+- Vibration feedback on button presses
+- Debug interface showing processed input values
+- Proper audio parameter updates with deadzone-compensated values
+
+**Technical Achievements:**
+- Eliminated stick drift through configurable deadzones with scaling
+- Solved button bounce issues with timeout-based debouncing
+- Unified controller mapping across different brands
+- Improved performance with event-driven vs polling approach
+
+## Controller Information
+- **Current Controller**: Hori third-party Nintendo Controller
+- **Controller ID**: "0f0d-00f6 Lic Pro Controller"
+- **Controller Type**: Nintendo (detected by GamepadMapper)
+- **Status**: Currently in the process of mapping all buttons accurately
+- **Trigger Names**: ZL (left trigger), ZR (right trigger), L (left bumper), R (right bumper)
 
-**Debug Interface Added:**
-- Real-time stick position display
-- Trigger pressure values
-- Button press indicators with brackets [A] [B] [X] [Y]
-- Updates immediately in polling loop to isolate latency source
+## Recent Changes
+- **DPAD Axis Mapping Implementation**: Made DPAD axis immune to dead zone filtering
+- **Raw Value Mapping**: Mapped Nintendo controller DPAD raw values to degrees:
+  - 1.29 = neutral (not pressed) → null
+  - 0.14 = down → 270°
+  - -0.14 = down right → 315°
+  - -0.43 = right → 0°
+  - -0.71 = up right → 45°
+  - -1 = up → 90°
+  - 1 = up left → 135°
+  - 0.71 = left → 180°
+  - 0.43 = down left → 225°
+- **User-Friendly Display**: Updated DPAD display to show directional names (e.g., "Up-Right (45°)")
+- **Dead Zone Immunity**: DPAD axis now bypasses dead zone filtering for precise directional input
+- Fixed triggers display in gamepad debug interface
+- Now shows proper button names based on controller type:
+  - Nintendo: ZL/ZR (triggers), L/R (bumpers)
+  - PlayStation: L2/R2 (triggers), L1/R1 (bumpers)  
+  - Xbox/Standard: LT/RT (triggers), LB/RB (bumpers)
 
 ## Immediate Next Steps
-1. Analyze latency source using debug interface
-2. Optimize audio parameter updates for lower latency
-3. Add pattern sequencer with step-based recording
-4. Implement multiple instrument tracks
-5. Add effects chain controls
-6. Create preset saving/loading system
+1. Test the fixed triggers display with the Hori Nintendo controller
+2. Continue mapping accuracy verification for all buttons
+3. Fine-tune deadzone and debounce settings based on real-world usage
+4. Consider adding more advanced features like:
+   - Custom controller mapping configuration
+   - Input recording/playback for testing
+   - Advanced vibration patterns
+5. Potential future enhancements:
+   - Pattern sequencer functionality
+   - Multiple instrument tracks
+   - Effects chain controls
+   - Preset saving/loading system

+ 496 - 0
src/gamepad-mapper.js

@@ -0,0 +1,496 @@
+/**
+ * GamepadMapper - A comprehensive gamepad mapping and input handling system
+ * Addresses shortcomings of the vanilla Gamepad API:
+ * - Stick drift compensation with configurable deadzones
+ * - Button debouncing to prevent multiple rapid presses
+ * - Standardized controller mapping across different brands
+ * - Event-driven API instead of polling
+ */
+
+export class GamepadMapper {
+    constructor(options = {}) {
+        // Configuration
+        this.config = {
+            deadzone: options.deadzone || 0.1,
+            debounceTime: options.debounceTime || 50, // ms
+            pollRate: options.pollRate || 60, // fps
+            enableVibration: options.enableVibration || false,
+            ...options
+        };
+
+        // State tracking
+        this.connectedGamepads = new Map();
+        this.previousStates = new Map();
+        this.buttonDebounceTimers = new Map();
+        this.eventListeners = new Map();
+        this.isPolling = false;
+
+        // Standard controller mappings
+        this.controllerMappings = this.initializeControllerMappings();
+
+        this.init();
+    }
+
+    init() {
+        // Set up gamepad connection events
+        window.addEventListener('gamepadconnected', this.handleGamepadConnected.bind(this));
+        window.addEventListener('gamepaddisconnected', this.handleGamepadDisconnected.bind(this));
+
+        // Check for already connected gamepads
+        this.scanForGamepads();
+
+        // Start polling loop
+        this.startPolling();
+    }
+
+    initializeControllerMappings() {
+        return {
+            // Standard mapping (most Xbox-compatible controllers)
+            'standard': {
+                buttons: {
+                    0: 'A',      // Bottom face button
+                    1: 'B',      // Right face button  
+                    2: 'X',      // Left face button
+                    3: 'Y',      // Top face button
+                    4: 'LB',     // Left bumper
+                    5: 'RB',     // Right bumper
+                    6: 'LT',     // Left trigger (digital)
+                    7: 'RT',     // Right trigger (digital)
+                    8: 'SELECT', // Select/Back button
+                    9: 'START',  // Start/Menu button
+                    10: 'LS',    // Left stick press
+                    11: 'RS',    // Right stick press
+                    12: 'DPAD_UP',
+                    13: 'DPAD_DOWN',
+                    14: 'DPAD_LEFT',
+                    15: 'DPAD_RIGHT',
+                    16: 'HOME'   // Home/Guide button (if available)
+                },
+                axes: {
+                    0: 'LEFT_STICK_X',
+                    1: 'LEFT_STICK_Y',
+                    2: 'RIGHT_STICK_X',
+                    3: 'RIGHT_STICK_Y',
+                    // Some controllers have analog triggers on axes 2/3 instead
+                    // We'll detect this dynamically
+                }
+            },
+
+            // PlayStation controllers (DS4, DualSense)
+            'playstation': {
+                buttons: {
+                    0: 'X',      // Cross (bottom)
+                    1: 'CIRCLE', // Circle (right)
+                    2: 'SQUARE', // Square (left)
+                    3: 'TRIANGLE', // Triangle (top)
+                    4: 'L1',     // Left bumper
+                    5: 'R1',     // Right bumper
+                    6: 'L2',     // Left trigger (digital)
+                    7: 'R2',     // Right trigger (digital)
+                    8: 'SHARE',  // Share button
+                    9: 'OPTIONS', // Options button
+                    10: 'L3',    // Left stick press
+                    11: 'R3',    // Right stick press
+                    12: 'DPAD_UP',
+                    13: 'DPAD_DOWN',
+                    14: 'DPAD_LEFT',
+                    15: 'DPAD_RIGHT',
+                    16: 'PS'     // PlayStation button
+                },
+                axes: {
+                    0: 'LEFT_STICK_X',
+                    1: 'LEFT_STICK_Y',
+                    2: 'RIGHT_STICK_X',
+                    3: 'RIGHT_STICK_Y'
+                }
+            },
+
+            // Nintendo Switch Pro Controller
+            'nintendo': {
+                buttons: {
+                    0: 'B',      // B (bottom)
+                    1: 'A',      // A (right)
+                    2: 'Y',      // Y (left)
+                    3: 'X',      // X (top)
+                    4: 'L',      // Left bumper
+                    5: 'R',      // Right bumper
+                    6: 'ZL',     // Left trigger
+                    7: 'ZR',     // Right trigger
+                    8: 'MINUS',  // Minus button
+                    9: 'PLUS',   // Plus button
+                    10: 'LS',    // Left stick press
+                    11: 'RS',    // Right stick press
+                    12: 'HOME',  // Home button
+                    13: 'SCREENSHOT' // Screenshot button
+                },
+                axes: {
+                    0: 'DPAD_AXIS',      // Nintendo DPAD on axis 0 (interferes with LEFT_STICK_Y)
+                    1: 'LEFT_STICK_X',   // Nintendo left stick X on axis 1
+                    2: 'LEFT_STICK_Y',   // Nintendo left stick Y on axis 2 (was showing as RIGHT_STICK_X)
+                    3: 'RIGHT_STICK_X',  // Nintendo right stick X on axis 3 (was showing as RIGHT_STICK_Y)
+                    4: 'RIGHT_STICK_Y'   // Nintendo right stick Y on axis 4 (Hori controller)
+                }
+            }
+        };
+    }
+
+    detectControllerType(gamepad) {
+        const id = gamepad.id.toLowerCase();
+
+        // PlayStation controllers
+        if (id.includes('playstation') || id.includes('dualshock') || id.includes('dualsense') ||
+            id.includes('054c')) { // Sony vendor ID
+            return 'playstation';
+        }
+
+        // Nintendo controllers
+        if (id.includes('nintendo') || id.includes('pro controller') || id.includes('057e')) { // Nintendo vendor ID
+            return 'nintendo';
+        }
+
+        // Xbox and most other controllers use standard mapping
+        return 'standard';
+    }
+
+    handleGamepadConnected(event) {
+        const gamepad = event.gamepad;
+        const controllerType = this.detectControllerType(gamepad);
+
+        console.log(`🎮 Gamepad connected: ${gamepad.id} (Type: ${controllerType})`);
+
+        this.connectedGamepads.set(gamepad.index, {
+            gamepad,
+            type: controllerType,
+            mapping: this.controllerMappings[controllerType],
+            lastUpdate: 0
+        });
+
+        this.previousStates.set(gamepad.index, {
+            buttons: {},
+            axes: {}
+        });
+
+        this.emit('connected', {
+            index: gamepad.index,
+            id: gamepad.id,
+            type: controllerType
+        });
+    }
+
+    handleGamepadDisconnected(event) {
+        const index = event.gamepad.index;
+        console.log(`🎮 Gamepad disconnected: ${event.gamepad.id}`);
+
+        this.connectedGamepads.delete(index);
+        this.previousStates.delete(index);
+
+        // Clear any pending debounce timers
+        if (this.buttonDebounceTimers.has(index)) {
+            const timers = this.buttonDebounceTimers.get(index);
+            Object.values(timers).forEach(timer => clearTimeout(timer));
+            this.buttonDebounceTimers.delete(index);
+        }
+
+        this.emit('disconnected', {
+            index: index,
+            id: event.gamepad.id
+        });
+    }
+
+    scanForGamepads() {
+        const gamepads = navigator.getGamepads();
+        for (let i = 0; i < gamepads.length; i++) {
+            if (gamepads[i] && !this.connectedGamepads.has(i)) {
+                // Simulate connection event for already connected gamepads
+                this.handleGamepadConnected({ gamepad: gamepads[i] });
+            }
+        }
+    }
+
+    startPolling() {
+        if (this.isPolling) return;
+        this.isPolling = true;
+
+        const poll = () => {
+            if (!this.isPolling) return;
+
+            this.updateGamepadStates();
+            setTimeout(() => requestAnimationFrame(poll), 1000 / this.config.pollRate);
+        };
+
+        requestAnimationFrame(poll);
+    }
+
+    stopPolling() {
+        this.isPolling = false;
+    }
+
+    updateGamepadStates() {
+        const gamepads = navigator.getGamepads();
+
+        for (const [index, controllerData] of this.connectedGamepads) {
+            const gamepad = gamepads[index];
+            if (!gamepad) continue;
+
+            const previousState = this.previousStates.get(index);
+            const currentState = {
+                buttons: {},
+                axes: {}
+            };
+
+            // Process buttons
+            this.processButtons(gamepad, controllerData, previousState, currentState);
+
+            // Process axes (analog sticks, triggers)
+            this.processAxes(gamepad, controllerData, previousState, currentState);
+
+            // Update previous state
+            this.previousStates.set(index, currentState);
+        }
+    }
+
+    processButtons(gamepad, controllerData, previousState, currentState) {
+        const mapping = controllerData.mapping.buttons;
+
+        gamepad.buttons.forEach((button, buttonIndex) => {
+            const buttonName = mapping[buttonIndex];
+            if (!buttonName) return;
+
+            const isPressed = button.pressed;
+            const wasPressed = previousState.buttons[buttonName] || false;
+
+            currentState.buttons[buttonName] = isPressed;
+
+            // Handle analog triggers as axes if they have analog values
+            if ((buttonName === 'LT' || buttonName === 'RT' || buttonName === 'L2' || buttonName === 'R2') && button.value > 0) {
+                const axisName = buttonName === 'LT' || buttonName === 'L2' ? 'LEFT_TRIGGER' : 'RIGHT_TRIGGER';
+                const previousValue = previousState.axes[axisName] || 0;
+
+                currentState.axes[axisName] = button.value;
+
+                // Emit axis change for analog triggers
+                if (Math.abs(button.value - previousValue) > 0.01) {
+                    this.emit('axischange', {
+                        gamepadIndex: gamepad.index,
+                        axis: axisName,
+                        value: button.value,
+                        rawValue: button.value
+                    });
+                }
+            }
+
+            // Detect button press (with debouncing)
+            if (isPressed && !wasPressed) {
+                this.handleButtonPress(gamepad.index, buttonName, button.value);
+            }
+
+            // Detect button release
+            if (!isPressed && wasPressed) {
+                this.handleButtonRelease(gamepad.index, buttonName);
+            }
+        });
+    }
+
+    processAxes(gamepad, controllerData, previousState, currentState) {
+        const mapping = controllerData.mapping.axes;
+
+        gamepad.axes.forEach((axisValue, axisIndex) => {
+            const axisName = mapping[axisIndex];
+            if (!axisName) return;
+
+            // DPAD_AXIS is immune to deadzone filtering and gets special processing
+            let processedValue;
+            if (axisName === 'DPAD_AXIS') {
+                processedValue = this.processDpadAxis(axisValue);
+            } else {
+                // Apply deadzone to all other axes
+                processedValue = this.applyDeadzone(axisValue);
+            }
+
+            const previousValue = previousState.axes[axisName] || 0;
+            currentState.axes[axisName] = processedValue;
+
+            // Only emit events if value changed significantly
+            const threshold = axisName === 'DPAD_AXIS' ? 0.001 : 0.01;
+            if (Math.abs(processedValue - previousValue) > threshold) {
+                this.emit('axischange', {
+                    gamepadIndex: gamepad.index,
+                    axis: axisName,
+                    value: processedValue,
+                    rawValue: axisValue
+                });
+            }
+        });
+    }
+
+    processDpadAxis(rawValue) {
+        // DPAD axis mapping for Nintendo controllers (raw values to degrees):
+        // 1.29 = neutral (not pressed) -> null
+        // 0.14 = down -> 270°
+        // -0.14 = down right -> 315°
+        // -0.43 = right -> 0°
+        // -0.71 = up right -> 45°
+        // -1 = up -> 90°
+        // 1 = up left -> 135°
+        // 0.71 = left -> 180°
+        // 0.43 = down left -> 225°
+
+        // Use tolerance for floating point comparison
+        const tolerance = 0.05;
+
+        // Check for neutral position first
+        if (Math.abs(rawValue - 1.29) < tolerance) {
+            return null; // Not pressed
+        }
+
+        // Map raw values to degrees
+        if (Math.abs(rawValue - (-0.43)) < tolerance) {
+            return 0; // Right
+        } else if (Math.abs(rawValue - (-0.71)) < tolerance) {
+            return 45; // Up right
+        } else if (Math.abs(rawValue - (-1)) < tolerance) {
+            return 90; // Up
+        } else if (Math.abs(rawValue - 1) < tolerance) {
+            return 135; // Up left
+        } else if (Math.abs(rawValue - 0.71) < tolerance) {
+            return 180; // Left
+        } else if (Math.abs(rawValue - 0.43) < tolerance) {
+            return 225; // Down left
+        } else if (Math.abs(rawValue - 0.14) < tolerance) {
+            return 270; // Down
+        } else if (Math.abs(rawValue - (-0.14)) < tolerance) {
+            return 315; // Down right
+        }
+
+        // Return null for unrecognized values (treat as neutral)
+        return null;
+    }
+
+    applyDeadzone(value) {
+        if (Math.abs(value) < this.config.deadzone) {
+            return 0;
+        }
+
+        // Scale the remaining range to 0-1
+        const sign = Math.sign(value);
+        const scaledValue = (Math.abs(value) - this.config.deadzone) / (1 - this.config.deadzone);
+        return sign * scaledValue;
+    }
+
+    handleButtonPress(gamepadIndex, buttonName, value) {
+        // Implement debouncing
+        const timerId = `${gamepadIndex}_${buttonName}`;
+
+        if (!this.buttonDebounceTimers.has(gamepadIndex)) {
+            this.buttonDebounceTimers.set(gamepadIndex, {});
+        }
+
+        const gamepadTimers = this.buttonDebounceTimers.get(gamepadIndex);
+
+        if (gamepadTimers[buttonName]) {
+            clearTimeout(gamepadTimers[buttonName]);
+        }
+
+        gamepadTimers[buttonName] = setTimeout(() => {
+            delete gamepadTimers[buttonName];
+
+            this.emit('buttondown', {
+                gamepadIndex,
+                button: buttonName,
+                value: value || 1
+            });
+        }, this.config.debounceTime);
+    }
+
+    handleButtonRelease(gamepadIndex, buttonName) {
+        this.emit('buttonup', {
+            gamepadIndex,
+            button: buttonName
+        });
+    }
+
+    // Event system
+    on(eventType, callback) {
+        if (!this.eventListeners.has(eventType)) {
+            this.eventListeners.set(eventType, []);
+        }
+        this.eventListeners.get(eventType).push(callback);
+    }
+
+    off(eventType, callback) {
+        if (!this.eventListeners.has(eventType)) return;
+
+        const listeners = this.eventListeners.get(eventType);
+        const index = listeners.indexOf(callback);
+        if (index > -1) {
+            listeners.splice(index, 1);
+        }
+    }
+
+    emit(eventType, data) {
+        if (!this.eventListeners.has(eventType)) return;
+
+        this.eventListeners.get(eventType).forEach(callback => {
+            try {
+                callback(data);
+            } catch (error) {
+                console.error(`Error in gamepad event listener for ${eventType}:`, error);
+            }
+        });
+    }
+
+    // Utility methods
+    getGamepadInfo(index) {
+        return this.connectedGamepads.get(index);
+    }
+
+    getConnectedGamepads() {
+        return Array.from(this.connectedGamepads.keys());
+    }
+
+    setDeadzone(deadzone) {
+        this.config.deadzone = Math.max(0, Math.min(1, deadzone));
+    }
+
+    setDebounceTime(time) {
+        this.config.debounceTime = Math.max(0, time);
+    }
+
+    // Vibration support (if available)
+    vibrate(gamepadIndex, duration = 200, strongMagnitude = 1.0, weakMagnitude = 1.0) {
+        if (!this.config.enableVibration) return false;
+
+        const gamepads = navigator.getGamepads();
+        const gamepad = gamepads[gamepadIndex];
+
+        if (!gamepad || !gamepad.vibrationActuator) return false;
+
+        try {
+            gamepad.vibrationActuator.playEffect('dual-rumble', {
+                duration,
+                strongMagnitude: Math.max(0, Math.min(1, strongMagnitude)),
+                weakMagnitude: Math.max(0, Math.min(1, weakMagnitude))
+            });
+            return true;
+        } catch (error) {
+            console.warn('Vibration not supported:', error);
+            return false;
+        }
+    }
+
+    destroy() {
+        this.stopPolling();
+
+        // Clear all timers
+        for (const timers of this.buttonDebounceTimers.values()) {
+            Object.values(timers).forEach(timer => clearTimeout(timer));
+        }
+
+        // Clear all event listeners
+        this.eventListeners.clear();
+
+        // Remove window event listeners
+        window.removeEventListener('gamepadconnected', this.handleGamepadConnected);
+        window.removeEventListener('gamepaddisconnected', this.handleGamepadDisconnected);
+    }
+}

+ 183 - 96
src/index.js

@@ -1,4 +1,5 @@
 import * as Tone from 'tone';
+import { GamepadMapper } from './gamepad-mapper.js';
 
 class DawdlehornApp {
     constructor() {
@@ -17,6 +18,14 @@ class DawdlehornApp {
         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'),
@@ -26,11 +35,18 @@ class DawdlehornApp {
             filterDisplay: document.getElementById('filter-display'),
             leftStickDisplay: document.getElementById('left-stick-display'),
             rightStickDisplay: document.getElementById('right-stick-display'),
-            triggersDisplay: document.getElementById('triggers-display'),
+            dpadDisplay: document.getElementById('dpad-display'),
             buttonsDisplay: document.getElementById('buttons-display'),
             visualizer: document.getElementById('visualizer')
         };
 
+        // Initialize gamepad mapper
+        this.gamepadMapper = new GamepadMapper({
+            deadzone: 0.15,
+            debounceTime: 30,
+            enableVibration: true
+        });
+
         this.init();
     }
 
@@ -53,21 +69,36 @@ class DawdlehornApp {
     }
 
     setupGamepadDetection() {
-        window.addEventListener('gamepadconnected', (e) => {
-            console.log('🎮 Gamepad connected:', e.gamepad.id);
+        // 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 = e.gamepad.index;
-            this.elements.gamepadStatus.textContent = `Connected: ${e.gamepad.id}`;
+            this.gamepadIndex = data.index;
+            this.elements.gamepadStatus.textContent = `Connected: ${data.id} (${data.type})`;
             this.elements.gamepadStatus.style.color = '#00ff00';
         });
 
-        window.addEventListener('gamepaddisconnected', (e) => {
+        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() {
@@ -110,120 +141,176 @@ class DawdlehornApp {
     }
 
     startGamepadPolling() {
-        const pollGamepad = () => {
-            if (this.gamepadConnected && this.gamepadIndex !== null) {
-                const gamepad = navigator.getGamepads()[this.gamepadIndex];
-                if (gamepad) {
-                    this.handleGamepadInput(gamepad);
+        // 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';
             }
-            requestAnimationFrame(pollGamepad);
+            requestAnimationFrame(updateLoop);
         };
-        pollGamepad();
+        updateLoop();
     }
 
-    handleGamepadInput(gamepad) {
+    handleButtonPress(button, value) {
         try {
-            // Update debug displays FIRST - this shows raw input immediately
-            // Standard gamepad mapping: axes[0]=left stick X, axes[1]=left stick Y, axes[2]=right stick X, axes[3]=right stick Y
-            const leftStickX = gamepad.axes[0] || 0;
-            const leftStickY = gamepad.axes[1] || 0;
-            const rightStickX = gamepad.axes[2] || 0;
-            const rightStickY = gamepad.axes[3] || 0;
-
-            this.elements.leftStickDisplay.textContent = `${leftStickX.toFixed(2)}, ${leftStickY.toFixed(2)}`;
-            this.elements.rightStickDisplay.textContent = `${rightStickX.toFixed(2)}, ${rightStickY.toFixed(2)}`;
-
-            // Triggers: L2=buttons[6], R2=buttons[7] (analog values)
-            const leftTrigger = gamepad.buttons[6] ? gamepad.buttons[6].value : 0;
-            const rightTrigger = gamepad.buttons[7] ? gamepad.buttons[7].value : 0;
-            this.elements.triggersDisplay.textContent = `L: ${leftTrigger.toFixed(2)} R: ${rightTrigger.toFixed(2)}`;
-
-            // Face buttons: A=0, B=1, X=2, Y=3
-            const buttonA = gamepad.buttons[0] && gamepad.buttons[0].pressed;
-            const buttonB = gamepad.buttons[1] && gamepad.buttons[1].pressed;
-            const buttonX = gamepad.buttons[2] && gamepad.buttons[2].pressed;
-            const buttonY = gamepad.buttons[3] && gamepad.buttons[3].pressed;
-
-            let buttonDisplay = '';
-            buttonDisplay += buttonA ? '[A]' : 'A';
-            buttonDisplay += ' ';
-            buttonDisplay += buttonB ? '[B]' : 'B';
-            buttonDisplay += ' ';
-            buttonDisplay += buttonX ? '[X]' : 'X';
-            buttonDisplay += ' ';
-            buttonDisplay += buttonY ? '[Y]' : 'Y';
-            this.elements.buttonsDisplay.textContent = buttonDisplay;
-
-            // Early return if audio not ready
+            console.log(`🎮 Button pressed: ${button} (${value})`);
+            this.gamepadState.buttons.add(button);
+
             if (!this.audioInitialized) return;
 
-            // Left stick X-axis controls frequency (200Hz - 2000Hz)
-            const newFreq = 440 + (leftStickX * 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`;
-            }
 
-            // Right stick Y-axis controls filter frequency (100Hz - 5000Hz)
-            const newFilter = 1000 + (rightStickY * -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`;
-            }
+            // 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
+            };
 
-            // Triggers control volume
-            const triggerVolume = (leftTrigger + rightTrigger) / 2;
-            if (Math.abs(triggerVolume - this.currentVolume) > 0.05) {
-                this.currentVolume = triggerVolume;
-                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)}%`;
+            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);
+                }
             }
 
-            // Face buttons control note playing
-            const anyButtonPressed = buttonA || buttonB || buttonX || buttonY;
+        } 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 (anyButtonPressed && !this.isPlaying) {
-                this.synth.start();
-                this.isPlaying = true;
-            } else if (!anyButtonPressed && this.isPlaying) {
+            if (!anyFaceButtonPressed && this.isPlaying) {
                 this.synth.stop();
                 this.isPlaying = false;
             }
 
-            // Different buttons trigger different note frequencies
-            if (buttonA) this.synth.frequency.setValueAtTime(261.63, Tone.now()); // C4
-            if (buttonB) this.synth.frequency.setValueAtTime(293.66, Tone.now()); // D4
-            if (buttonX) this.synth.frequency.setValueAtTime(329.63, Tone.now()); // E4
-            if (buttonY) this.synth.frequency.setValueAtTime(349.23, Tone.now()); // F4
-
         } catch (error) {
-            console.error('❌ Gamepad input error:', error);
-
-            // Emergency stop audio on any error
-            if (this.isPlaying && this.synth) {
-                try {
-                    this.synth.stop();
-                    this.isPlaying = false;
-                } catch (stopError) {
-                    console.error('❌ Failed to stop synth:', stopError);
-                }
+            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;
             }
 
-            // Update UI to show error state
-            this.elements.buttonsDisplay.textContent = 'ERROR - Audio Stopped';
-            this.elements.buttonsDisplay.style.color = '#ff0000';
 
-            // Reset error state after 2 seconds
-            setTimeout(() => {
-                this.elements.buttonsDisplay.style.color = '#00ff00';
-            }, 2000);
+        } 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');