diff --git a/src/components/DrumPadWidget.jsx b/src/components/DrumPadWidget.jsx new file mode 100644 index 0000000..5098b67 --- /dev/null +++ b/src/components/DrumPadWidget.jsx @@ -0,0 +1,122 @@ +import React, { useCallback, useRef, useState } from 'react'; +import { triggerKeyboard } from '../engine/audioEngine.js'; +import { state } from '../engine/state.js'; +import { useIsMobile } from '../hooks/useIsMobile.js'; + +// 4x4 pad layout โ€” each pad maps to a MIDI note +const PAD_NOTES = [ + { note: 36, label: 'C2', color: '#ff4466' }, + { note: 38, label: 'D2', color: '#ff6644' }, + { note: 40, label: 'E2', color: '#ffcc00' }, + { note: 42, label: 'F#2', color: '#44ff88' }, + { note: 43, label: 'G2', color: '#00e5ff' }, + { note: 45, label: 'A2', color: '#aa55ff' }, + { note: 47, label: 'B2', color: '#ff4466' }, + { note: 48, label: 'C3', color: '#ff6644' }, + { note: 50, label: 'D3', color: '#ffcc00' }, + { note: 52, label: 'E3', color: '#44ff88' }, + { note: 53, label: 'F3', color: '#00e5ff' }, + { note: 55, label: 'G3', color: '#aa55ff' }, + { note: 57, label: 'A3', color: '#ff4466' }, + { note: 59, label: 'B3', color: '#ff6644' }, + { note: 60, label: 'C4', color: '#ffcc00' }, + { note: 62, label: 'D4', color: '#44ff88' }, +]; + +function midiToFreq(midi) { + return 440 * Math.pow(2, (midi - 69) / 12); +} + +function FullscreenDrumPad({ moduleId, onClose }) { + const [activePad, setActivePad] = useState(-1); + + const hitPad = useCallback((pad, idx) => { + triggerKeyboard(moduleId, midiToFreq(pad.note), true); + setActivePad(idx); + setTimeout(() => { + triggerKeyboard(moduleId, midiToFreq(pad.note), false); + setActivePad(-1); + }, 150); + }, [moduleId]); + + return ( +
+
+ ๐Ÿฅ Drum Pads + +
+
+ {PAD_NOTES.map((pad, i) => ( +
hitPad(pad, i)} + > + {pad.label} + {i + 1} +
+ ))} +
+
+ ); +} + +export default function DrumPadWidget({ moduleId }) { + const isMobile = useIsMobile(); + const [fullscreen, setFullscreen] = useState(false); + const [activePad, setActivePad] = useState(-1); + const lastTap = useRef(0); + + const hitPad = useCallback((pad, idx) => { + triggerKeyboard(moduleId, midiToFreq(pad.note), true); + setActivePad(idx); + setTimeout(() => { + triggerKeyboard(moduleId, midiToFreq(pad.note), false); + setActivePad(-1); + }, 150); + }, [moduleId]); + + const handleDoubleTap = useCallback((e) => { + const now = Date.now(); + if (now - lastTap.current < 350) { + e.preventDefault(); + e.stopPropagation(); + setFullscreen(true); + } + lastTap.current = now; + }, []); + + return ( + <> +
+
+ {PAD_NOTES.map((pad, i) => ( +
{ e.stopPropagation(); hitPad(pad, i); }} + > + {pad.label} +
+ ))} +
+
+ {isMobile ? 'Doble-tap: pantalla completa' : 'Tap pads to trigger'} +
+
+ + {fullscreen && ( + setFullscreen(false)} /> + )} + + ); +} diff --git a/src/components/KeyboardWidget.jsx b/src/components/KeyboardWidget.jsx index 6afc1ba..ac98edd 100644 --- a/src/components/KeyboardWidget.jsx +++ b/src/components/KeyboardWidget.jsx @@ -1,10 +1,10 @@ -import React, { useCallback, useEffect, useRef } from 'react'; +import React, { useCallback, useEffect, useRef, useState } from 'react'; import { triggerKeyboard } from '../engine/audioEngine.js'; import { state } from '../engine/state.js'; +import { useIsMobile } from '../hooks/useIsMobile.js'; const NOTE_NAMES = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B']; -// Computer keyboard to semitone offset mapping (2 octaves) const KEY_MAP = { 'z': 0, 's': 1, 'x': 2, 'd': 3, 'c': 4, 'v': 5, 'g': 6, 'b': 7, 'h': 8, 'n': 9, 'j': 10, 'm': 11, ',': 12, @@ -16,10 +16,97 @@ function midiToFreq(midi) { return 440 * Math.pow(2, (midi - 69) / 12); } +// Fullscreen piano overlay for mobile +function FullscreenPiano({ moduleId, octave, onClose }) { + const activeRef = useRef(new Set()); + + const playNote = useCallback((semitone) => { + const midi = (octave + 1) * 12 + semitone; + triggerKeyboard(moduleId, midiToFreq(midi), true); + }, [moduleId, octave]); + + const stopNote = useCallback(() => { + triggerKeyboard(moduleId, 440, false); + }, [moduleId]); + + const handleTouch = useCallback((semitone, isDown) => { + if (isDown) { + activeRef.current.add(semitone); + playNote(semitone); + } else { + activeRef.current.delete(semitone); + if (activeRef.current.size === 0) stopNote(); + } + }, [playNote, stopNote]); + + // 2 octaves of keys + const whites = []; + const blacks = []; + const whiteNotes = [0, 2, 4, 5, 7, 9, 11]; + const blackNotes = [1, 3, -1, 6, 8, 10, -1]; + + for (let oct = 0; oct < 2; oct++) { + const offset = oct * 12; + whiteNotes.forEach((note, i) => { + whites.push({ note: note + offset, idx: whites.length }); + }); + } + + const blackPositions = [ + { note: 1, whiteIdx: 0 }, { note: 3, whiteIdx: 1 }, + { note: 6, whiteIdx: 3 }, { note: 8, whiteIdx: 4 }, { note: 10, whiteIdx: 5 }, + { note: 13, whiteIdx: 7 }, { note: 15, whiteIdx: 8 }, + { note: 18, whiteIdx: 10 }, { note: 20, whiteIdx: 11 }, { note: 22, whiteIdx: 12 }, + ]; + + const totalWhites = whites.length; + const keyW = 100 / totalWhites; + + return ( +
+
+ ๐ŸŽน Piano โ€” Oct {octave} + +
+
+ {/* White keys */} + {whites.map((k, i) => ( +
handleTouch(k.note, true)} + onPointerUp={() => handleTouch(k.note, false)} + onPointerLeave={() => handleTouch(k.note, false)} + > + + {NOTE_NAMES[k.note % 12]} + +
+ ))} + {/* Black keys */} + {blackPositions.map((bp, i) => ( +
handleTouch(bp.note, true)} + onPointerUp={() => handleTouch(bp.note, false)} + onPointerLeave={() => handleTouch(bp.note, false)} + /> + ))} +
+
+ ); +} + export default function KeyboardWidget({ moduleId }) { const mod = state.modules.find(m => m.id === moduleId); const octave = mod?.params?.octave ?? 4; const activeKeys = useRef(new Set()); + const isMobile = useIsMobile(); + const [fullscreen, setFullscreen] = useState(false); + const lastTap = useRef(0); const playNote = useCallback((semitone) => { const midi = (octave + 1) * 12 + semitone; @@ -47,7 +134,6 @@ export default function KeyboardWidget({ moduleId }) { if (activeKeys.current.size === 0) stopNote(); } }; - window.addEventListener('keydown', handleDown); window.addEventListener('keyup', handleUp); return () => { @@ -56,36 +142,56 @@ export default function KeyboardWidget({ moduleId }) { }; }, [playNote, stopNote]); - // Draw mini keyboard (1 octave) + const handleDoubleTap = useCallback((e) => { + const now = Date.now(); + if (now - lastTap.current < 350) { + e.preventDefault(); + e.stopPropagation(); + setFullscreen(true); + } + lastTap.current = now; + }, []); + + // Mini keyboard (1 octave) const whites = [0, 2, 4, 5, 7, 9, 11]; const blacks = [1, 3, -1, 6, 8, 10]; return ( -
- - {whites.map((note, i) => ( - playNote(note)} - onPointerUp={stopNote} - /> - ))} - {blacks.filter(n => n >= 0).map((note, i) => { - const pos = [1, 2, 4, 5, 6][i]; - return ( - +
+ + {whites.map((note, i) => ( + playNote(note)} onPointerUp={stopNote} /> - ); - })} - -
- Z-M / Q-I keys ยท Oct {octave} + ))} + {blacks.filter(n => n >= 0).map((note, i) => { + const pos = [1, 2, 4, 5, 6][i]; + return ( + playNote(note)} + onPointerUp={stopNote} + /> + ); + })} + +
+ {isMobile ? 'Doble-tap: pantalla completa' : 'Z-M / Q-I keys'} ยท Oct {octave} +
-
+ + {fullscreen && ( + setFullscreen(false)} + /> + )} + ); } diff --git a/src/components/ModuleNode.jsx b/src/components/ModuleNode.jsx index 9116795..4f006ef 100644 --- a/src/components/ModuleNode.jsx +++ b/src/components/ModuleNode.jsx @@ -5,6 +5,7 @@ import { updateParam } from '../engine/audioEngine.js'; import Knob from './Knob.jsx'; import ScopeDisplay from './ScopeDisplay.jsx'; import KeyboardWidget from './KeyboardWidget.jsx'; +import DrumPadWidget from './DrumPadWidget.jsx'; import SequencerWidget from './SequencerWidget.jsx'; import PianoRollWidget from './PianoRollWidget.jsx'; @@ -248,6 +249,9 @@ export default function ModuleNode({ mod, zoom, onStartConnect, onPortPosition } {/* Keyboard widget */} {mod.type === 'keyboard' && } + {/* Drum Pad widget */} + {mod.type === 'drumpad' && } + {/* Sequencer widget */} {mod.type === 'sequencer' && } diff --git a/src/engine/audioEngine.js b/src/engine/audioEngine.js index a1af47e..0cf9f4d 100644 --- a/src/engine/audioEngine.js +++ b/src/engine/audioEngine.js @@ -170,7 +170,8 @@ function createNode(mod) { }, }; } - case 'keyboard': { + case 'keyboard': + case 'drumpad': { const freqSig = new Tone.Signal(440); const gateSig = new Tone.Signal(0); return { @@ -251,7 +252,7 @@ export function connectWire(conn) { // set the oscillator frequency directly when notes are played. const fromMod = state.modules.find(m => m.id === conn.from.moduleId); const toMod = state.modules.find(m => m.id === conn.to.moduleId); - if (fromMod && ['keyboard', 'sequencer', 'pianoroll'].includes(fromMod.type) && + if (fromMod && ['keyboard', 'drumpad', 'sequencer', 'pianoroll'].includes(fromMod.type) && conn.from.port === 'freq' && toMod?.type === 'oscillator' && conn.to.port === 'freq') { return; // handled imperatively in triggerKeyboard / setSequencerSignals } @@ -354,6 +355,7 @@ export function updateParam(moduleId, paramName, value) { if (paramName === 'volume') entry.node.gain.value = Tone.dbToGain(value); break; case 'keyboard': + case 'drumpad': case 'sequencer': case 'pianoroll': // All params stored in state, managed by widgets diff --git a/src/engine/moduleRegistry.js b/src/engine/moduleRegistry.js index 0982902..5b7250e 100644 --- a/src/engine/moduleRegistry.js +++ b/src/engine/moduleRegistry.js @@ -258,6 +258,20 @@ defineModule('keyboard', { }, }); +// ==================== DRUM PAD ==================== + +defineModule('drumpad', { + name: 'Drum Pad', + icon: '๐Ÿฅ', + category: 'Source', + inputs: [], + outputs: [ + { name: 'freq', type: PORT_TYPE.AUDIO, label: 'Freq' }, + { name: 'gate', type: PORT_TYPE.TRIGGER, label: 'Gate' }, + ], + params: {}, +}); + // ==================== SEQUENCER ==================== defineModule('sequencer', { diff --git a/src/index.css b/src/index.css index b789ff2..87ebc40 100644 --- a/src/index.css +++ b/src/index.css @@ -786,6 +786,99 @@ html, body, #root { .admin-star-btn.zero { color: var(--red); } .admin-star-btn.zero:hover { border-color: var(--red); color: var(--red); } +/* ===== Fullscreen Keyboard ===== */ +.keyboard-fullscreen { + position: fixed; inset: 0; z-index: 500; + background: #050510; display: flex; flex-direction: column; + animation: fadeIn 0.2s ease-out; +} +@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } } + +.keyboard-fs-header { + display: flex; align-items: center; justify-content: space-between; + padding: 12px 16px; background: var(--panel); border-bottom: 1px solid var(--border); +} +.keyboard-fs-title { font-size: 14px; font-weight: 600; color: var(--text); } +.keyboard-fs-close { + width: 36px; height: 36px; border-radius: 8px; + background: var(--surface); border: 1px solid var(--border); + color: var(--text); font-size: 16px; cursor: pointer; + display: flex; align-items: center; justify-content: center; +} + +.keyboard-fs-keys { + flex: 1; position: relative; display: flex; + padding: 0; touch-action: none; user-select: none; +} +.keyboard-fs-white { + flex-shrink: 0; height: 100%; background: #1a1a2e; + border-right: 1px solid #252545; position: relative; + display: flex; align-items: flex-end; justify-content: center; + padding-bottom: 16px; cursor: pointer; transition: background 0.05s; +} +.keyboard-fs-white:active { background: var(--accent); } +.keyboard-fs-note-label { + font-size: 10px; color: var(--text2); font-family: 'JetBrains Mono', monospace; + pointer-events: none; +} +.keyboard-fs-white:active .keyboard-fs-note-label { color: #000; } + +.keyboard-fs-black { + position: absolute; top: 0; height: 55%; + background: #0a0a18; border: 1px solid #333; + border-top: none; border-radius: 0 0 4px 4px; + z-index: 2; cursor: pointer; transition: background 0.05s; +} +.keyboard-fs-black:active { background: var(--accent); } + +/* ===== Drum Pad ===== */ +.drumpad-grid { + display: grid; grid-template-columns: repeat(4, 1fr); + gap: 3px; padding: 2px 0; +} +.drumpad-pad { + aspect-ratio: 1; border-radius: 4px; + display: flex; align-items: center; justify-content: center; + cursor: pointer; transition: all 0.05s; border: 1px solid var(--border); + font-size: 8px; font-weight: 600; color: var(--text2); + font-family: 'JetBrains Mono', monospace; user-select: none; + touch-action: none; +} +.drumpad-pad:active { transform: scale(0.92); } +.drumpad-pad.active { border-color: var(--accent); box-shadow: 0 0 8px rgba(0,229,255,0.3); } + +.drumpad-fullscreen { + position: fixed; inset: 0; z-index: 500; + background: #050510; display: flex; flex-direction: column; + animation: fadeIn 0.2s ease-out; +} +.drumpad-fs-header { + display: flex; align-items: center; justify-content: space-between; + padding: 12px 16px; background: var(--panel); border-bottom: 1px solid var(--border); +} +.drumpad-fs-title { font-size: 14px; font-weight: 600; color: var(--text); } +.drumpad-fs-close { + width: 36px; height: 36px; border-radius: 8px; + background: var(--surface); border: 1px solid var(--border); + color: var(--text); font-size: 16px; cursor: pointer; + display: flex; align-items: center; justify-content: center; +} +.drumpad-fs-grid { + flex: 1; display: grid; grid-template-columns: repeat(4, 1fr); + gap: 8px; padding: 16px; touch-action: none; +} +.drumpad-fs-pad { + border-radius: 8px; display: flex; flex-direction: column; + align-items: center; justify-content: center; gap: 4px; + cursor: pointer; transition: all 0.05s; + border: 2px solid var(--border); font-size: 12px; + font-weight: 600; color: var(--text2); + font-family: 'JetBrains Mono', monospace; + user-select: none; touch-action: none; +} +.drumpad-fs-pad:active { transform: scale(0.95); border-color: var(--accent); } +.drumpad-fs-pad .pad-label { font-size: 10px; color: var(--text2); } + /* ============================================ MOBILE RESPONSIVE โ€” max-width: 768px ============================================ */