index.js 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341
  1. import * as Tone from 'tone';
  2. import { GamepadMapper } from './gamepad-mapper.js';
  3. class DawdlehornApp {
  4. constructor() {
  5. this.audioInitialized = false;
  6. this.gamepadConnected = false;
  7. this.gamepadIndex = null;
  8. // Audio components
  9. this.synth = null;
  10. this.filter = null;
  11. this.volume = null;
  12. // State
  13. this.currentFreq = 440;
  14. this.currentVolume = 0.5;
  15. this.currentFilter = 1000;
  16. this.isPlaying = false;
  17. // Gamepad state tracking
  18. this.gamepadState = {
  19. leftStick: { x: 0, y: 0 },
  20. rightStick: { x: 0, y: 0 },
  21. dpadAxis: 0,
  22. buttons: new Set()
  23. };
  24. // UI elements
  25. this.elements = {
  26. gamepadStatus: document.getElementById('gamepad-status'),
  27. audioStatus: document.getElementById('audio-status'),
  28. freqDisplay: document.getElementById('freq-display'),
  29. volumeDisplay: document.getElementById('volume-display'),
  30. filterDisplay: document.getElementById('filter-display'),
  31. leftStickDisplay: document.getElementById('left-stick-display'),
  32. rightStickDisplay: document.getElementById('right-stick-display'),
  33. dpadDisplay: document.getElementById('dpad-display'),
  34. buttonsDisplay: document.getElementById('buttons-display'),
  35. visualizer: document.getElementById('visualizer')
  36. };
  37. // Initialize gamepad mapper
  38. this.gamepadMapper = new GamepadMapper({
  39. deadzone: 0.15,
  40. debounceTime: 30,
  41. enableVibration: true
  42. });
  43. this.init();
  44. }
  45. async init() {
  46. console.log('🎮 Initializing Dawdlehorn...');
  47. // Set up gamepad detection
  48. this.setupGamepadDetection();
  49. // Set up audio (will be initialized on first user interaction)
  50. this.setupAudioComponents();
  51. // Start gamepad polling
  52. this.startGamepadPolling();
  53. // Set up visualizer
  54. this.setupVisualizer();
  55. console.log('✅ Dawdlehorn initialized');
  56. }
  57. setupGamepadDetection() {
  58. // Set up gamepad mapper event listeners
  59. this.gamepadMapper.on('connected', (data) => {
  60. console.log(`🎮 Gamepad connected: ${data.id} (Type: ${data.type})`);
  61. this.gamepadConnected = true;
  62. this.gamepadIndex = data.index;
  63. this.elements.gamepadStatus.textContent = `Connected: ${data.id} (${data.type})`;
  64. this.elements.gamepadStatus.style.color = '#00ff00';
  65. });
  66. this.gamepadMapper.on('disconnected', (data) => {
  67. console.log('🎮 Gamepad disconnected');
  68. this.gamepadConnected = false;
  69. this.gamepadIndex = null;
  70. this.elements.gamepadStatus.textContent = 'Not Connected';
  71. this.elements.gamepadStatus.style.color = '#ff6600';
  72. });
  73. // Handle button events
  74. this.gamepadMapper.on('buttondown', (data) => {
  75. this.handleButtonPress(data.button, data.value);
  76. });
  77. this.gamepadMapper.on('buttonup', (data) => {
  78. this.handleButtonRelease(data.button);
  79. });
  80. // Handle axis changes
  81. this.gamepadMapper.on('axischange', (data) => {
  82. this.handleAxisChange(data.axis, data.value, data.rawValue);
  83. });
  84. }
  85. async setupAudioComponents() {
  86. try {
  87. // Create audio chain: Synth -> Filter -> Volume -> Destination
  88. this.synth = new Tone.Oscillator(440, 'sawtooth');
  89. this.filter = new Tone.Filter(1000, 'lowpass');
  90. this.volume = new Tone.Volume(-20);
  91. // Connect the audio chain
  92. this.synth.connect(this.filter);
  93. this.filter.connect(this.volume);
  94. this.volume.toDestination();
  95. this.elements.audioStatus.textContent = 'Ready (Click to start)';
  96. this.elements.audioStatus.style.color = '#ffff00';
  97. // Set up click-to-start audio
  98. document.addEventListener('click', this.initializeAudio.bind(this), { once: true });
  99. } catch (error) {
  100. console.error('❌ Audio setup failed:', error);
  101. this.elements.audioStatus.textContent = 'Setup Failed';
  102. this.elements.audioStatus.style.color = '#ff0000';
  103. }
  104. }
  105. async initializeAudio() {
  106. try {
  107. await Tone.start();
  108. console.log('🔊 Audio context started');
  109. this.audioInitialized = true;
  110. this.elements.audioStatus.textContent = 'Active';
  111. this.elements.audioStatus.style.color = '#00ff00';
  112. } catch (error) {
  113. console.error('❌ Audio initialization failed:', error);
  114. this.elements.audioStatus.textContent = 'Failed';
  115. this.elements.audioStatus.style.color = '#ff0000';
  116. }
  117. }
  118. startGamepadPolling() {
  119. // The GamepadMapper handles polling internally, so we just need to update the debug display
  120. this.updateDebugDisplay();
  121. }
  122. updateDebugDisplay() {
  123. const updateLoop = () => {
  124. if (this.gamepadConnected) {
  125. // Update debug displays with current gamepad state
  126. this.elements.leftStickDisplay.textContent = `${this.gamepadState.leftStick.x.toFixed(2)}, ${this.gamepadState.leftStick.y.toFixed(2)}`;
  127. this.elements.rightStickDisplay.textContent = `${this.gamepadState.rightStick.x.toFixed(2)}, ${this.gamepadState.rightStick.y.toFixed(2)}`;
  128. this.elements.dpadDisplay.textContent = `${this.gamepadState.dpadAxis.toFixed(2)}`;
  129. // Update button display - show ALL pressed buttons dynamically
  130. let buttonDisplay = '';
  131. // Convert Set to Array and sort for consistent display order
  132. const pressedButtons = Array.from(this.gamepadState.buttons).sort();
  133. pressedButtons.forEach((button, index) => {
  134. if (buttonDisplay) buttonDisplay += ' ';
  135. buttonDisplay += `[${button}]`;
  136. });
  137. this.elements.buttonsDisplay.textContent = buttonDisplay || 'None pressed';
  138. }
  139. requestAnimationFrame(updateLoop);
  140. };
  141. updateLoop();
  142. }
  143. handleButtonPress(button, value) {
  144. try {
  145. console.log(`🎮 Button pressed: ${button} (${value})`);
  146. this.gamepadState.buttons.add(button);
  147. if (!this.audioInitialized) return;
  148. // Map face buttons to notes with proper debouncing
  149. const noteMap = {
  150. 'A': 261.63, // C4
  151. 'B': 293.66, // D4
  152. 'X': 329.63, // E4
  153. 'Y': 349.23 // F4
  154. };
  155. if (noteMap[button]) {
  156. if (!this.isPlaying) {
  157. this.synth.start();
  158. this.isPlaying = true;
  159. }
  160. this.synth.frequency.setValueAtTime(noteMap[button], Tone.now());
  161. // Add vibration feedback if available
  162. if (this.gamepadIndex !== null) {
  163. this.gamepadMapper.vibrate(this.gamepadIndex, 100, 0.3, 0.1);
  164. }
  165. }
  166. } catch (error) {
  167. console.error('❌ Button press error:', error);
  168. }
  169. }
  170. handleButtonRelease(button) {
  171. try {
  172. console.log(`🎮 Button released: ${button}`);
  173. this.gamepadState.buttons.delete(button);
  174. if (!this.audioInitialized) return;
  175. // Stop audio if no face buttons are pressed
  176. const faceButtons = ['A', 'B', 'X', 'Y'];
  177. const anyFaceButtonPressed = faceButtons.some(btn => this.gamepadState.buttons.has(btn));
  178. if (!anyFaceButtonPressed && this.isPlaying) {
  179. this.synth.stop();
  180. this.isPlaying = false;
  181. }
  182. } catch (error) {
  183. console.error('❌ Button release error:', error);
  184. }
  185. }
  186. handleAxisChange(axis, value, rawValue) {
  187. try {
  188. // Debug logging for stick issues (can be removed in production)
  189. // if (axis.includes('STICK')) {
  190. // console.log(`🕹️ ${axis}: processed=${value.toFixed(3)}, raw=${rawValue.toFixed(3)}`);
  191. // }
  192. // Update gamepad state
  193. switch (axis) {
  194. case 'LEFT_STICK_X':
  195. this.gamepadState.leftStick.x = value;
  196. this.updateFrequency();
  197. break;
  198. case 'LEFT_STICK_Y':
  199. this.gamepadState.leftStick.y = value;
  200. break;
  201. case 'RIGHT_STICK_X':
  202. this.gamepadState.rightStick.x = value;
  203. break;
  204. case 'RIGHT_STICK_Y':
  205. this.gamepadState.rightStick.y = value;
  206. this.updateFilter();
  207. break;
  208. case 'DPAD_AXIS':
  209. this.gamepadState.dpadAxis = value;
  210. break;
  211. }
  212. } catch (error) {
  213. console.error('❌ Axis change error:', error);
  214. }
  215. }
  216. updateFrequency() {
  217. if (!this.audioInitialized) return;
  218. // Left stick X-axis controls frequency (200Hz - 2000Hz)
  219. const newFreq = 440 + (this.gamepadState.leftStick.x * 560); // 440Hz ± 560Hz
  220. if (Math.abs(newFreq - this.currentFreq) > 5) {
  221. this.currentFreq = newFreq;
  222. this.synth.frequency.setValueAtTime(this.currentFreq, Tone.now());
  223. this.elements.freqDisplay.textContent = `${Math.round(this.currentFreq)} Hz`;
  224. }
  225. }
  226. updateFilter() {
  227. if (!this.audioInitialized) return;
  228. // Right stick Y-axis controls filter frequency (100Hz - 5000Hz)
  229. const newFilter = 1000 + (this.gamepadState.rightStick.y * -2000); // Inverted Y, 1000Hz ± 2000Hz
  230. if (Math.abs(newFilter - this.currentFilter) > 50) {
  231. this.currentFilter = Math.max(100, Math.min(5000, newFilter));
  232. this.filter.frequency.setValueAtTime(this.currentFilter, Tone.now());
  233. this.elements.filterDisplay.textContent = `${Math.round(this.currentFilter)} Hz`;
  234. }
  235. }
  236. updateVolume() {
  237. if (!this.audioInitialized) return;
  238. // Simple volume control - could be enhanced later if needed
  239. // For now, just keep the current volume logic without trigger dependency
  240. const dbVolume = -40 + (this.currentVolume * 40); // -40dB to 0dB
  241. this.volume.volume.setValueAtTime(dbVolume, Tone.now());
  242. this.elements.volumeDisplay.textContent = `${Math.round(this.currentVolume * 100)}%`;
  243. }
  244. setupVisualizer() {
  245. const canvas = this.elements.visualizer;
  246. const ctx = canvas.getContext('2d');
  247. // Set canvas size
  248. const resizeCanvas = () => {
  249. canvas.width = canvas.offsetWidth;
  250. canvas.height = canvas.offsetHeight;
  251. };
  252. resizeCanvas();
  253. window.addEventListener('resize', resizeCanvas);
  254. // Simple visualizer animation
  255. const animate = () => {
  256. ctx.fillStyle = '#000';
  257. ctx.fillRect(0, 0, canvas.width, canvas.height);
  258. if (this.isPlaying) {
  259. const time = Date.now() * 0.01;
  260. const centerY = canvas.height / 2;
  261. ctx.strokeStyle = '#00ff00';
  262. ctx.lineWidth = 2;
  263. ctx.beginPath();
  264. for (let x = 0; x < canvas.width; x += 2) {
  265. const freq = this.currentFreq / 1000;
  266. const y = centerY + Math.sin((x * 0.02) + time * freq) * (this.currentVolume * 30);
  267. if (x === 0) ctx.moveTo(x, y);
  268. else ctx.lineTo(x, y);
  269. }
  270. ctx.stroke();
  271. }
  272. requestAnimationFrame(animate);
  273. };
  274. animate();
  275. }
  276. }
  277. // Initialize the app when the page loads
  278. document.addEventListener('DOMContentLoaded', () => {
  279. new DawdlehornApp();
  280. });