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.baseFreq = 440; // Base frequency before pitch bending this.currentVolume = 0.5; this.currentFilter = 1000; this.isPlaying = false; this.pitchBend = 0; // -1 to 1, where -1 is flat, 1 is sharp // Gamepad state tracking this.gamepadState = { leftStick: { x: 0, y: 0 }, rightStick: { x: 0, y: 0 }, dpadAxis: 0, buttons: new Set() }; // C-Major scale frequencies (C4 to C5) this.cMajorScale = { 'C': 261.63, // C4 'D': 293.66, // D4 'E': 329.63, // E4 'F': 349.23, // F4 'G': 392.00, // G4 'A': 440.00, // A4 'B': 493.88, // B4 'C5': 523.25 // C5 (octave) }; // 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; // Face buttons (A, B, X, Y) are reserved for future functionality // Add vibration feedback for any button press 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; // Face buttons (A, B, X, Y) no longer control audio // Audio is now controlled solely by right stick movement } 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; break; case 'LEFT_STICK_Y': this.gamepadState.leftStick.y = value; this.updatePitchBend(); break; case 'RIGHT_STICK_X': this.gamepadState.rightStick.x = value; this.updateRightStickControls(); break; case 'RIGHT_STICK_Y': this.gamepadState.rightStick.y = value; this.updateRightStickControls(); break; case 'DPAD_AXIS': this.gamepadState.dpadAxis = value; break; } } catch (error) { console.error('❌ Axis change error:', error); } } updatePitchBend() { if (!this.audioInitialized) return; // Left stick Y-axis controls pitch bend (-1 to 1, where -1 is flat, 1 is sharp) // Invert Y axis so up is positive (sharp), down is negative (flat) this.pitchBend = -this.gamepadState.leftStick.y; // Apply pitch bend to current frequency this.updateFrequency(); } updateRightStickControls() { if (!this.audioInitialized) return; const x = this.gamepadState.rightStick.x; const y = this.gamepadState.rightStick.y; // Calculate magnitude (0 to 1) for volume control const magnitude = Math.sqrt(x * x + y * y); this.currentVolume = Math.min(1, magnitude); // Calculate angle for C-Major scale selection // Convert from gamepad coordinates to degrees (0° = right, 90° = up, etc.) let angle = Math.atan2(-y, x) * (180 / Math.PI); // -y to match screen coordinates if (angle < 0) angle += 360; // Normalize to 0-360° // Map angles to C-Major scale notes // Down = 270° = C, Down-left = 225° = D, Left = 180° = E, etc. const noteFromAngle = this.getNoteFromAngle(angle, magnitude); if (noteFromAngle && magnitude > 0.1) { // Only play if stick is moved significantly this.baseFreq = this.cMajorScale[noteFromAngle]; this.updateFrequency(); // Start playing if not already playing if (!this.isPlaying) { this.synth.start(); this.isPlaying = true; } } else if (magnitude <= 0.1 && this.isPlaying) { // Stop playing if stick is back to neutral this.synth.stop(); this.isPlaying = false; } // Update volume this.updateVolume(); // Update displays this.elements.volumeDisplay.textContent = `${Math.round(this.currentVolume * 100)}%`; if (magnitude > 0.1) { const noteDisplay = noteFromAngle ? `${noteFromAngle} (${Math.round(angle)}°)` : `${Math.round(angle)}°`; this.elements.filterDisplay.textContent = noteDisplay; } else { this.elements.filterDisplay.textContent = 'Neutral'; } } getNoteFromAngle(angle, magnitude) { if (magnitude < 0.1) return null; // Dead zone // Map 8 directions to C-Major scale // Each direction covers 45° (360° / 8 = 45°) const directions = [ { min: 337.5, max: 22.5, note: 'E' }, // Right (0°) = E { min: 22.5, max: 67.5, note: 'F' }, // Up-right (45°) = F { min: 67.5, max: 112.5, note: 'G' }, // Up (90°) = G { min: 112.5, max: 157.5, note: 'A' }, // Up-left (135°) = A { min: 157.5, max: 202.5, note: 'B' }, // Left (180°) = B { min: 202.5, max: 247.5, note: 'C5' }, // Down-left (225°) = C5 (octave) { min: 247.5, max: 292.5, note: 'C' }, // Down (270°) = C { min: 292.5, max: 337.5, note: 'D' } // Down-right (315°) = D ]; for (const dir of directions) { if (dir.min > dir.max) { // Handle wrap-around (e.g., 337.5° to 22.5°) if (angle >= dir.min || angle <= dir.max) { return dir.note; } } else { if (angle >= dir.min && angle <= dir.max) { return dir.note; } } } return null; } updateFrequency() { if (!this.audioInitialized) return; // Apply pitch bend to base frequency // Pitch bend range: ±1 semitone (±100 cents) const bendFactor = Math.pow(2, this.pitchBend / 12); // 1 semitone = 1/12 octave const newFreq = this.baseFreq * bendFactor; if (Math.abs(newFreq - this.currentFreq) > 1) { this.currentFreq = newFreq; this.synth.frequency.setValueAtTime(this.currentFreq, Tone.now()); // Display frequency with pitch bend indicator let freqText = `${Math.round(this.currentFreq)} Hz`; if (Math.abs(this.pitchBend) > 0.05) { const bendCents = Math.round(this.pitchBend * 100); const bendSymbol = bendCents > 0 ? '♯' : '♭'; freqText += ` ${bendSymbol}${Math.abs(bendCents)}¢`; } this.elements.freqDisplay.textContent = freqText; } } 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(); });