index.js 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342
  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. triggers: { left: 0, right: 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. triggersDisplay: document.getElementById('triggers-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.triggersDisplay.textContent = `L: ${this.gamepadState.triggers.left.toFixed(2)} R: ${this.gamepadState.triggers.right.toFixed(2)}`;
  129. // Update button display
  130. let buttonDisplay = '';
  131. buttonDisplay += this.gamepadState.buttons.has('A') ? '[A]' : 'A';
  132. buttonDisplay += ' ';
  133. buttonDisplay += this.gamepadState.buttons.has('B') ? '[B]' : 'B';
  134. buttonDisplay += ' ';
  135. buttonDisplay += this.gamepadState.buttons.has('X') ? '[X]' : 'X';
  136. buttonDisplay += ' ';
  137. buttonDisplay += this.gamepadState.buttons.has('Y') ? '[Y]' : 'Y';
  138. this.elements.buttonsDisplay.textContent = buttonDisplay;
  139. }
  140. requestAnimationFrame(updateLoop);
  141. };
  142. updateLoop();
  143. }
  144. handleButtonPress(button, value) {
  145. try {
  146. console.log(`🎮 Button pressed: ${button} (${value})`);
  147. this.gamepadState.buttons.add(button);
  148. if (!this.audioInitialized) return;
  149. // Map face buttons to notes with proper debouncing
  150. const noteMap = {
  151. 'A': 261.63, // C4
  152. 'B': 293.66, // D4
  153. 'X': 329.63, // E4
  154. 'Y': 349.23 // F4
  155. };
  156. if (noteMap[button]) {
  157. if (!this.isPlaying) {
  158. this.synth.start();
  159. this.isPlaying = true;
  160. }
  161. this.synth.frequency.setValueAtTime(noteMap[button], Tone.now());
  162. // Add vibration feedback if available
  163. if (this.gamepadIndex !== null) {
  164. this.gamepadMapper.vibrate(this.gamepadIndex, 100, 0.3, 0.1);
  165. }
  166. }
  167. } catch (error) {
  168. console.error('❌ Button press error:', error);
  169. }
  170. }
  171. handleButtonRelease(button) {
  172. try {
  173. console.log(`🎮 Button released: ${button}`);
  174. this.gamepadState.buttons.delete(button);
  175. if (!this.audioInitialized) return;
  176. // Stop audio if no face buttons are pressed
  177. const faceButtons = ['A', 'B', 'X', 'Y'];
  178. const anyFaceButtonPressed = faceButtons.some(btn => this.gamepadState.buttons.has(btn));
  179. if (!anyFaceButtonPressed && this.isPlaying) {
  180. this.synth.stop();
  181. this.isPlaying = false;
  182. }
  183. } catch (error) {
  184. console.error('❌ Button release error:', error);
  185. }
  186. }
  187. handleAxisChange(axis, value, rawValue) {
  188. try {
  189. // Update gamepad state
  190. switch (axis) {
  191. case 'LEFT_STICK_X':
  192. this.gamepadState.leftStick.x = value;
  193. this.updateFrequency();
  194. break;
  195. case 'LEFT_STICK_Y':
  196. this.gamepadState.leftStick.y = value;
  197. break;
  198. case 'RIGHT_STICK_X':
  199. this.gamepadState.rightStick.x = value;
  200. break;
  201. case 'RIGHT_STICK_Y':
  202. this.gamepadState.rightStick.y = value;
  203. this.updateFilter();
  204. break;
  205. }
  206. // Handle analog triggers (some controllers map triggers to axes)
  207. if (axis === 'LEFT_TRIGGER' || axis === 'RIGHT_TRIGGER') {
  208. if (axis === 'LEFT_TRIGGER') {
  209. this.gamepadState.triggers.left = value;
  210. } else {
  211. this.gamepadState.triggers.right = value;
  212. }
  213. this.updateVolume();
  214. }
  215. } catch (error) {
  216. console.error('❌ Axis change error:', error);
  217. }
  218. }
  219. updateFrequency() {
  220. if (!this.audioInitialized) return;
  221. // Left stick X-axis controls frequency (200Hz - 2000Hz)
  222. const newFreq = 440 + (this.gamepadState.leftStick.x * 560); // 440Hz ± 560Hz
  223. if (Math.abs(newFreq - this.currentFreq) > 5) {
  224. this.currentFreq = newFreq;
  225. this.synth.frequency.setValueAtTime(this.currentFreq, Tone.now());
  226. this.elements.freqDisplay.textContent = `${Math.round(this.currentFreq)} Hz`;
  227. }
  228. }
  229. updateFilter() {
  230. if (!this.audioInitialized) return;
  231. // Right stick Y-axis controls filter frequency (100Hz - 5000Hz)
  232. const newFilter = 1000 + (this.gamepadState.rightStick.y * -2000); // Inverted Y, 1000Hz ± 2000Hz
  233. if (Math.abs(newFilter - this.currentFilter) > 50) {
  234. this.currentFilter = Math.max(100, Math.min(5000, newFilter));
  235. this.filter.frequency.setValueAtTime(this.currentFilter, Tone.now());
  236. this.elements.filterDisplay.textContent = `${Math.round(this.currentFilter)} Hz`;
  237. }
  238. }
  239. updateVolume() {
  240. if (!this.audioInitialized) return;
  241. // Triggers control volume
  242. const triggerVolume = (this.gamepadState.triggers.left + this.gamepadState.triggers.right) / 2;
  243. if (Math.abs(triggerVolume - this.currentVolume) > 0.05) {
  244. this.currentVolume = triggerVolume;
  245. const dbVolume = -40 + (this.currentVolume * 40); // -40dB to 0dB
  246. this.volume.volume.setValueAtTime(dbVolume, Tone.now());
  247. this.elements.volumeDisplay.textContent = `${Math.round(this.currentVolume * 100)}%`;
  248. }
  249. }
  250. setupVisualizer() {
  251. const canvas = this.elements.visualizer;
  252. const ctx = canvas.getContext('2d');
  253. // Set canvas size
  254. const resizeCanvas = () => {
  255. canvas.width = canvas.offsetWidth;
  256. canvas.height = canvas.offsetHeight;
  257. };
  258. resizeCanvas();
  259. window.addEventListener('resize', resizeCanvas);
  260. // Simple visualizer animation
  261. const animate = () => {
  262. ctx.fillStyle = '#000';
  263. ctx.fillRect(0, 0, canvas.width, canvas.height);
  264. if (this.isPlaying) {
  265. const time = Date.now() * 0.01;
  266. const centerY = canvas.height / 2;
  267. ctx.strokeStyle = '#00ff00';
  268. ctx.lineWidth = 2;
  269. ctx.beginPath();
  270. for (let x = 0; x < canvas.width; x += 2) {
  271. const freq = this.currentFreq / 1000;
  272. const y = centerY + Math.sin((x * 0.02) + time * freq) * (this.currentVolume * 30);
  273. if (x === 0) ctx.moveTo(x, y);
  274. else ctx.lineTo(x, y);
  275. }
  276. ctx.stroke();
  277. }
  278. requestAnimationFrame(animate);
  279. };
  280. animate();
  281. }
  282. }
  283. // Initialize the app when the page loads
  284. document.addEventListener('DOMContentLoaded', () => {
  285. new DawdlehornApp();
  286. });