+ {/* Mobile menu overlay */}
+ {isMobile && menuOpen && (
+
- {/* Status bar */}
+ {/* Mobile action bar */}
+ {isMobile && (
+
+ )}
+
+ {/* Mobile bottom sheet with modules */}
+ {isMobile && (
+
Reaktor โ MontLab Modular Synth
Zoom: {(state.zoom * 100).toFixed(0)}%
diff --git a/src/components/BottomSheet.jsx b/src/components/BottomSheet.jsx
new file mode 100644
index 0000000..d141cfe
--- /dev/null
+++ b/src/components/BottomSheet.jsx
@@ -0,0 +1,52 @@
+import { useState, useRef, useCallback } from 'react';
+
+export default function BottomSheet({ tabs, activeTab, onTabChange, children, className = '' }) {
+ const [expanded, setExpanded] = useState(false);
+ const startY = useRef(0);
+
+ const handleTouchStart = useCallback((e) => {
+ startY.current = e.touches[0].clientY;
+ }, []);
+
+ const handleTouchEnd = useCallback((e) => {
+ const deltaY = e.changedTouches[0].clientY - startY.current;
+ if (deltaY < -30) setExpanded(true);
+ if (deltaY > 30) setExpanded(false);
+ }, []);
+
+ return (
+
+
setExpanded(v => !v)}>
+
+ {!expanded && !tabs && (
+
Modulos โฒ
+ )}
+
+
+ {tabs && tabs.length > 0 && (
+
!expanded && setExpanded(true)}>
+ {tabs.map(tab => (
+
+ ))}
+
+ )}
+
+ {expanded && (
+
+ {children}
+
+ )}
+
+ );
+}
diff --git a/src/components/DrumPadWidget.jsx b/src/components/DrumPadWidget.jsx
new file mode 100644
index 0000000..70be250
--- /dev/null
+++ b/src/components/DrumPadWidget.jsx
@@ -0,0 +1,109 @@
+import React, { useCallback, useState } from 'react';
+import { createPortal } from 'react-dom';
+import { triggerKeyboard } from '../engine/audioEngine.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, fullscreen, onCloseFullscreen }) {
+ 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 (
+ <>
+
+
+ {PAD_NOTES.map((pad, i) => (
+
{ e.stopPropagation(); hitPad(pad, i); }}
+ >
+ {pad.label}
+
+ ))}
+
+
+ Tap pads to trigger
+
+
+
+ {fullscreen && createPortal(
+
,
+ document.body
+ )}
+ >
+ );
+}
diff --git a/src/components/KeyboardWidget.jsx b/src/components/KeyboardWidget.jsx
index 6afc1ba..63cc5f2 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 { createPortal } from 'react-dom';
import { triggerKeyboard } from '../engine/audioEngine.js';
import { state } from '../engine/state.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,7 +16,87 @@ function midiToFreq(midi) {
return 440 * Math.pow(2, (midi - 69) / 12);
}
-export default function KeyboardWidget({ moduleId }) {
+// Fullscreen piano โ 1 octave, big comfortable keys like a real piano app
+function FullscreenPiano({ moduleId, initialOctave, onClose }) {
+ const [oct, setOct] = useState(initialOctave);
+ const [activeNotes, setActiveNotes] = useState(new Set());
+
+ const play = useCallback((semitone) => {
+ const midi = (oct + 1) * 12 + semitone;
+ triggerKeyboard(moduleId, midiToFreq(midi), true);
+ setActiveNotes(prev => new Set(prev).add(semitone));
+ }, [moduleId, oct]);
+
+ const stop = useCallback((semitone) => {
+ setActiveNotes(prev => {
+ const next = new Set(prev);
+ next.delete(semitone);
+ if (next.size === 0) triggerKeyboard(moduleId, 440, false);
+ return next;
+ });
+ }, [moduleId]);
+
+ // 1 octave: 7 white keys, 5 black keys
+ const whiteKeys = [
+ { note: 0, name: 'C' },
+ { note: 2, name: 'D' },
+ { note: 4, name: 'E' },
+ { note: 5, name: 'F' },
+ { note: 7, name: 'G' },
+ { note: 9, name: 'A' },
+ { note: 11, name: 'B' },
+ ];
+
+ // Black key positions relative to white key index (0-6)
+ const blackKeys = [
+ { note: 1, name: 'C#', after: 0 },
+ { note: 3, name: 'D#', after: 1 },
+ { note: 6, name: 'F#', after: 3 },
+ { note: 8, name: 'G#', after: 4 },
+ { note: 10, name: 'A#', after: 5 },
+ ];
+
+ return (
+
+
+
+
+
Octave {oct}
+
+
+
+
+ {whiteKeys.map((k, i) => (
+
play(k.note)}
+ onPointerUp={() => stop(k.note)}
+ onPointerLeave={() => stop(k.note)}
+ onPointerCancel={() => stop(k.note)}
+ >
+ {k.name}{oct}
+
+ ))}
+ {blackKeys.map((k) => (
+
play(k.note)}
+ onPointerUp={() => stop(k.note)}
+ onPointerLeave={() => stop(k.note)}
+ onPointerCancel={() => stop(k.note)}
+ >
+ {k.name}
+
+ ))}
+
+
+ );
+}
+
+export default function KeyboardWidget({ moduleId, fullscreen, onCloseFullscreen }) {
const mod = state.modules.find(m => m.id === moduleId);
const octave = mod?.params?.octave ?? 4;
const activeKeys = useRef(new Set());
@@ -47,7 +127,6 @@ export default function KeyboardWidget({ moduleId }) {
if (activeKeys.current.size === 0) stopNote();
}
};
-
window.addEventListener('keydown', handleDown);
window.addEventListener('keyup', handleUp);
return () => {
@@ -56,36 +135,47 @@ export default function KeyboardWidget({ moduleId }) {
};
}, [playNote, stopNote]);
- // Draw mini keyboard (1 octave)
+ // Mini keyboard (1 octave)
const whites = [0, 2, 4, 5, 7, 9, 11];
const blacks = [1, 3, -1, 6, 8, 10];
return (
-
-