From cd88fb544487ad40c7ca053188be06f0f11be60a Mon Sep 17 00:00:00 2001 From: Jose Luis Date: Sat, 21 Mar 2026 15:40:50 +0100 Subject: [PATCH 01/22] feat: add mobile-responsive UI for all views - Add useIsMobile hook (768px breakpoint with matchMedia) - Add BottomSheet component (swipe up/down, optional tabs, handle bar) - Add MobileTabBar component (bottom nav with icons + labels) - Sandbox mobile: compact toolbar, hamburger menu, action bar with START button, bottom sheet with module grid tiles - World Map mobile: compact header, single-column level list, bottom tab bar (JUEGO/SANDBOX/CONFIG) - Puzzle View mobile: icon-only top bar buttons, sidebar replaced by bottom sheet with 3 tabs (MISION/OBJETIVOS/MODULOS) - ~200 lines of CSS media queries: touch targets 44px, port dots 18px, zoom controls larger, modals full-width, level complete full-width buttons Co-Authored-By: Claude Opus 4.6 (1M context) --- src/App.jsx | 134 +++++++++++++----- src/components/BottomSheet.jsx | 49 +++++++ src/components/MobileTabBar.jsx | 16 +++ src/game/PuzzleView.jsx | 105 ++++++++++++-- src/game/WorldMap.jsx | 21 +++ src/hooks/useIsMobile.js | 14 ++ src/index.css | 233 ++++++++++++++++++++++++++++++++ 7 files changed, 533 insertions(+), 39 deletions(-) create mode 100644 src/components/BottomSheet.jsx create mode 100644 src/components/MobileTabBar.jsx create mode 100644 src/hooks/useIsMobile.js diff --git a/src/App.jsx b/src/App.jsx index cbe4464..3e7ef56 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -8,7 +8,10 @@ import ModuleNode from './components/ModuleNode.jsx'; import WireLayer from './components/WireLayer.jsx'; import ModulePalette from './components/ModulePalette.jsx'; import PresetModal from './components/PresetModal.jsx'; +import BottomSheet from './components/BottomSheet.jsx'; import { CHIPTUNE_PRESET } from './presets/chiptune.js'; +import { useIsMobile } from './hooks/useIsMobile.js'; +import { getModulesByCategory } from './engine/moduleRegistry.js'; export default function App({ onSwitchToGame }) { const [, forceUpdate] = useState(0); @@ -18,6 +21,8 @@ export default function App({ onSwitchToGame }) { const connectingRef = useRef(null); const [presetModal, setPresetModal] = useState(null); const importRef = useRef(null); + const isMobile = useIsMobile(); + const [menuOpen, setMenuOpen] = useState(false); // Subscribe to state changes useEffect(() => { @@ -249,44 +254,82 @@ export default function App({ onSwitchToGame }) { emit(); }; + // Flatten all modules for mobile grid + const allModuleDefs = Object.values(getModulesByCategory()).flat(); + return (
{/* Toolbar */}
- {onSwitchToGame && ( + {onSwitchToGame && !isMobile && ( )} Reaktor -
- -
-
- - - - - -
-
- - -
- + {!isMobile &&
} + {!isMobile && ( + + )} + {!isMobile &&
} + {!isMobile && ( +
+ + + + + +
+ )} + {!isMobile &&
} + {!isMobile && ( + <> + + +
+ + )} + {state.isRunning ? 'โ— LIVE' : 'โ—‹ OFF'} - - {state.modules.length} modules ยท {state.connections.length} wires - + {!isMobile && ( + + {state.modules.length} modules ยท {state.connections.length} wires + + )} + {isMobile && ( + + )}
+ {/* Mobile menu overlay */} + {isMobile && menuOpen && ( +
setMenuOpen(false)}> +
e.stopPropagation()}> + {onSwitchToGame && ( + + )} + + + + + + + +
+
+ )} + {/* Main canvas area */}
- {/* Wire layer (behind modules, uses getBoundingClientRect) */} + {/* Wire layer */} - {/* Modules container (offset by camera) */} + {/* Modules container */}
{state.modules.map(mod => (
- {/* Zoom controls โ€” top right of canvas */} + {/* Zoom controls */}
- {/* Module palette */} - + {/* Desktop palette */} + {!isMobile && }
- {/* Status bar */} + {/* Mobile action bar */} + {isMobile && ( +
+ + + + +
+ )} + + {/* Mobile bottom sheet with modules */} + {isMobile && ( + +
+ {allModuleDefs.map(def => ( +
handleAddModule(def.type)}> + {def.icon} + {def.name} +
+ ))} +
+
+ )} + + {/* Status bar (hidden on mobile via CSS) */}
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..62ad89c --- /dev/null +++ b/src/components/BottomSheet.jsx @@ -0,0 +1,49 @@ +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 sheetRef = useRef(null); + + const handleTouchStart = useCallback((e) => { + startY.current = e.touches[0].clientY; + }, []); + + const handleTouchEnd = useCallback((e) => { + const deltaY = e.changedTouches[0].clientY - startY.current; + if (deltaY < -40) setExpanded(true); + if (deltaY > 40) setExpanded(false); + }, []); + + return ( +
+
setExpanded(e => !e)}> +
+
+ + {tabs && tabs.length > 0 && ( +
+ {tabs.map(tab => ( + + ))} +
+ )} + +
+ {children} +
+
+ ); +} diff --git a/src/components/MobileTabBar.jsx b/src/components/MobileTabBar.jsx new file mode 100644 index 0000000..8c226dc --- /dev/null +++ b/src/components/MobileTabBar.jsx @@ -0,0 +1,16 @@ +export default function MobileTabBar({ tabs, activeTab, onTabChange }) { + return ( + + ); +} diff --git a/src/game/PuzzleView.jsx b/src/game/PuzzleView.jsx index 32ce0d4..3d5b011 100644 --- a/src/game/PuzzleView.jsx +++ b/src/game/PuzzleView.jsx @@ -4,6 +4,8 @@ import { startAudio, stopAudio, connectWire, rebuildGraph } from '../engine/audi import { getModuleDef } from '../engine/moduleRegistry.js'; import ModuleNode from '../components/ModuleNode.jsx'; import WireLayer from '../components/WireLayer.jsx'; +import BottomSheet from '../components/BottomSheet.jsx'; +import { useIsMobile } from '../hooks/useIsMobile.js'; import { playTarget, stopTarget, isTargetPlaying } from './targetAudio.js'; import LevelComplete from './LevelComplete.jsx'; import { completeLevel, saveLevelPatch, getLevelPatch, clearLevelPatch, markHintUsed, wasHintUsed } from './gameState.js'; @@ -20,6 +22,8 @@ export default function PuzzleView({ level, levelIndex, worldLevels, onBack, onN const [showHint, setShowHint] = useState(false); const [result, setResult] = useState(null); const [targetPlaying, setTargetPlaying] = useState(false); + const isMobile = useIsMobile(); + const [mobileTab, setMobileTab] = useState('mission'); useEffect(() => { const unsub = subscribe(() => { @@ -335,7 +339,7 @@ export default function PuzzleView({ level, levelIndex, worldLevels, onBack, onN
{/* Top bar */}
- +
{levelIndex + 1}/{worldLevels.length} {level.title} @@ -345,19 +349,21 @@ export default function PuzzleView({ level, levelIndex, worldLevels, onBack, onN className={`gm-btn ${targetPlaying ? 'active' : 'target'}`} onClick={handlePlayTarget} > - {targetPlaying ? 'โน Parar' : '๐ŸŽฏ Objetivo'} + {targetPlaying ? 'โน' : '๐ŸŽฏ'}{!isMobile && {targetPlaying ? ' Parar' : ' Objetivo'}} - + {!isMobile && ( + + )} {adminMode && (
- {/* Left sidebar */} + {/* Left sidebar (desktop only โ€” hidden on mobile via CSS) */}
{/* Description โ€” always visible */}
@@ -502,6 +508,89 @@ export default function PuzzleView({ level, levelIndex, worldLevels, onBack, onN
+ {/* Mobile bottom sheet with tabs (replaces sidebar) */} + {isMobile && ( + + {mobileTab === 'mission' && ( +
+

{level.description}

+ {!showHint ? ( + + ) : ( +
+
๐Ÿ’ก Pista max โ˜…โ˜…
+

{level.concept}

+
+ )} +
+ )} + + {mobileTab === 'objectives' && ( +
+ {level.checks.map((check, i) => { + const passed = result?.checks?.[i]?.passed; + const cappedByStar = hintUsed && check.star === 3; + return ( +
+ {'โ˜…'.repeat(check.star)} + + {check.desc} + {cappedByStar && ' ๐Ÿ”’'} + + {passed === true && !cappedByStar && โœ“} + {passed === false && โœ—} +
+ ); + })} + {hintUsed && ( +
+ Pista usada โ€” maximo 2 estrellas (permanente). +
+ )} +
+ )} + + {mobileTab === 'modules' && ( +
+ {level.availableModules.length > 0 ? ( +
+ {level.availableModules.map(type => { + const def = getModuleDef(type); + if (!def) return null; + return ( +
handleAddModule(type)}> + {def.icon} + {def.name} +
+ ); + })} +
+ ) : ( +

No hay modulos extra disponibles para este nivel.

+ )} + + +
+ )} +
+ )} + {/* Level complete overlay */} {result && result.stars >= 1 && ( = world.unlockStars; } +const MOBILE_TABS = [ + { id: 'game', label: 'JUEGO', icon: '๐ŸŽฎ' }, + { id: 'sandbox', label: 'SANDBOX', icon: '๐ŸŽ›' }, + { id: 'config', label: 'CONFIG', icon: 'โš™' }, +]; + export default function WorldMap({ onSelectLevel, onSandbox, onAdmin }) { const totalStars = getTotalStars(); const maxStars = getMaxStars(); const [search, setSearch] = useState(''); const searchRef = useRef(null); + const isMobile = useIsMobile(); const query = search.trim().toLowerCase(); @@ -209,6 +218,18 @@ export default function WorldMap({ onSelectLevel, onSandbox, onAdmin }) { ); }) )} + + {/* Mobile tab bar */} + {isMobile && ( + { + if (id === 'sandbox') onSandbox?.(); + if (id === 'config') onAdmin?.(); + }} + /> + )}
); } diff --git a/src/hooks/useIsMobile.js b/src/hooks/useIsMobile.js new file mode 100644 index 0000000..5f65d8a --- /dev/null +++ b/src/hooks/useIsMobile.js @@ -0,0 +1,14 @@ +import { useState, useEffect } from 'react'; + +export function useIsMobile(breakpoint = 768) { + const [isMobile, setIsMobile] = useState(() => window.innerWidth <= breakpoint); + + useEffect(() => { + const mql = window.matchMedia(`(max-width: ${breakpoint}px)`); + const handler = (e) => setIsMobile(e.matches); + mql.addEventListener('change', handler); + return () => mql.removeEventListener('change', handler); + }, [breakpoint]); + + return isMobile; +} diff --git a/src/index.css b/src/index.css index bfce7b0..917e088 100644 --- a/src/index.css +++ b/src/index.css @@ -785,3 +785,236 @@ html, body, #root { .admin-star-btn.active { background: var(--yellow); color: var(--bg); border-color: var(--yellow); } .admin-star-btn.zero { color: var(--red); } .admin-star-btn.zero:hover { border-color: var(--red); color: var(--red); } + +/* ============================================ + MOBILE RESPONSIVE โ€” max-width: 768px + ============================================ */ + +/* --- Bottom Sheet --- */ +.bottom-sheet { + display: none; +} + +/* --- Mobile Tab Bar --- */ +.mobile-tab-bar { + display: none; +} + +@media (max-width: 768px) { + + /* --- Sandbox Toolbar --- */ + .toolbar { height: 44px; padding: 0 12px; gap: 6px; } + .toolbar-title { font-size: 13px; letter-spacing: 0.8px; } + .toolbar-sep, .toolbar .status-text, + .toolbar-btn.save-btn, .toolbar-btn.load-btn, + .toolbar-btn.export-btn, .toolbar-btn.import-btn, + .toolbar-btn.demo-btn, .toolbar-btn.clear-btn { display: none; } + .toolbar-btn.start-btn { padding: 4px 10px; font-size: 11px; } + + /* Hamburger menu button (added via JS) */ + .mobile-menu-btn { + padding: 6px 10px; background: var(--surface); border: 1px solid var(--border); + border-radius: 6px; color: var(--text2); cursor: pointer; font-size: 18px; + font-weight: 600; line-height: 1; + } + .mobile-menu-btn:hover { border-color: var(--accent); color: var(--text); } + + .mobile-menu-overlay { + position: fixed; inset: 0; background: rgba(0,0,0,0.6); + z-index: 200; display: flex; justify-content: flex-end; + } + .mobile-menu-panel { + width: 260px; background: var(--panel); border-left: 1px solid var(--border); + padding: 16px; display: flex; flex-direction: column; gap: 6px; + animation: slideInRight 0.2s ease-out; + } + @keyframes slideInRight { + from { transform: translateX(100%); } + to { transform: translateX(0); } + } + .mobile-menu-panel .toolbar-btn { + display: flex; width: 100%; padding: 10px 14px; + font-size: 13px; text-align: left; + } + + /* --- Mobile Action Bar (Sandbox) --- */ + .mobile-action-bar { + display: flex; align-items: center; gap: 8px; + padding: 0 12px; height: 48px; background: var(--panel); + border-top: 1px solid var(--border); flex-shrink: 0; + } + .mobile-action-bar .start-btn-mobile { + flex: 1; padding: 8px 14px; background: var(--accent); color: #000; + border: none; border-radius: 6px; font-size: 12px; font-weight: 700; + font-family: 'JetBrains Mono', monospace; letter-spacing: 1px; cursor: pointer; + text-transform: uppercase; + } + .mobile-action-bar .start-btn-mobile.active { + background: var(--red); + } + .mobile-action-bar .action-icon-btn { + padding: 8px 12px; background: var(--surface); border: 1px solid var(--border); + border-radius: 6px; color: var(--text2); cursor: pointer; font-size: 14px; + } + .mobile-action-bar .action-icon-btn:hover { border-color: var(--accent); } + + /* --- Status Bar --- */ + .status-bar { display: none; } + + /* --- Module Palette --- */ + .palette { display: none; } + + /* --- Bottom Sheet (visible on mobile) --- */ + .bottom-sheet { + display: flex; flex-direction: column; + background: var(--panel); border-top: 1px solid var(--border); + border-radius: 16px 16px 0 0; flex-shrink: 0; + max-height: 40vh; transition: max-height 0.3s ease; + overflow: hidden; + } + .bottom-sheet.expanded { max-height: 60vh; } + + .bottom-sheet-handle { + display: flex; justify-content: center; padding: 10px 0 6px; + cursor: grab; + } + .bottom-sheet-handle-bar { + width: 40px; height: 4px; background: var(--border); + border-radius: 2px; + } + + .bottom-sheet-tabs { + display: flex; padding: 0 16px; gap: 0; + border-bottom: 1px solid var(--border); + } + .bottom-sheet-tab { + flex: 1; padding: 8px 0; background: none; border: none; + color: var(--text2); font-size: 10px; font-weight: 700; + font-family: 'JetBrains Mono', monospace; letter-spacing: 1px; + cursor: pointer; text-align: center; position: relative; + text-transform: uppercase; + } + .bottom-sheet-tab.active { color: var(--accent); } + .bottom-sheet-tab-line { + position: absolute; bottom: 0; left: 50%; transform: translateX(-50%); + width: 100%; height: 2px; background: var(--accent); border-radius: 1px; + } + + .bottom-sheet-content { + padding: 12px 16px; overflow-y: auto; flex: 1; + } + + /* Module grid tiles (mobile palette) */ + .mobile-module-grid { + display: grid; grid-template-columns: repeat(5, 1fr); + gap: 8px; + } + .mobile-module-tile { + display: flex; flex-direction: column; align-items: center; gap: 4px; + padding: 10px 4px; background: var(--surface); border: 1px solid var(--border); + border-radius: 8px; cursor: pointer; transition: all 0.15s; + } + .mobile-module-tile:hover, .mobile-module-tile:active { + border-color: var(--accent); background: var(--surface2); + } + .mobile-module-tile .tile-icon { font-size: 20px; } + .mobile-module-tile .tile-name { + font-size: 9px; font-weight: 600; color: var(--text2); + font-family: 'JetBrains Mono', monospace; text-transform: uppercase; + } + + /* --- Canvas adjustments --- */ + .node-canvas { cursor: default; } + .zoom-controls { right: 8px; top: 8px; } + .zoom-btn { width: 40px; height: 36px; min-height: 44px; } + .port-dot { width: 18px; height: 18px; } + + /* --- Mobile Tab Bar (visible on mobile) --- */ + .mobile-tab-bar { + display: flex; align-items: center; height: 56px; + background: var(--panel); border-top: 1px solid var(--border); + flex-shrink: 0; z-index: 10; + } + .mobile-tab { + flex: 1; display: flex; flex-direction: column; align-items: center; + gap: 3px; padding: 6px 0; background: none; border: none; + cursor: pointer; color: var(--text2); transition: color 0.15s; + } + .mobile-tab.active { color: var(--accent); } + .mobile-tab-icon { font-size: 18px; } + .mobile-tab-label { + font-size: 9px; font-weight: 600; letter-spacing: 1px; + font-family: 'JetBrains Mono', monospace; text-transform: uppercase; + } + + /* --- World Map Mobile --- */ + .gm-worldmap { padding: 0 12px 80px; } + .gm-header { padding: 12px 0; gap: 6px; } + .gm-header-top { gap: 8px; } + .gm-logo-icon { width: 32px; height: 32px; font-size: 16px; } + .gm-title { font-size: 18px; } + .gm-tagline { display: none; } + .gm-header-actions .gm-btn { display: none; } + .gm-search-bar { margin: 0 0 12px; } + .gm-level-grid { grid-template-columns: 1fr; } + .gm-level-card { padding: 10px 12px; } + .gm-world-section { margin-bottom: 16px; } + + /* --- Puzzle View Mobile --- */ + .gm-puzzle-bar { height: 44px; padding: 0 10px; gap: 6px; } + .gm-puzzle-bar .gm-btn { padding: 6px 10px; } + .gm-puzzle-bar .gm-btn .btn-label { display: none; } + .gm-puzzle-name { font-size: 13px; } + .gm-puzzle-num { font-size: 9px; padding: 2px 6px; } + + .gm-puzzle-sidebar { display: none; } + .gm-puzzle-canvas-wrap { width: 100%; } + + /* Puzzle bottom sheet specific */ + .puzzle-mission-text { + font-size: 12px; color: var(--text); line-height: 1.5; + } + .puzzle-hint-btn { + display: flex; align-items: center; gap: 8px; + padding: 10px 12px; border: 1px dashed var(--yellow); + border-radius: 8px; background: rgba(255,204,0,0.04); + cursor: pointer; width: 100%; margin-top: 8px; + } + .puzzle-hint-btn:hover { background: rgba(255,204,0,0.1); border-style: solid; } + .puzzle-hint-icon { font-size: 16px; } + .puzzle-hint-label { font-size: 12px; font-weight: 600; color: var(--yellow); flex: 1; } + .puzzle-hint-penalty { + font-size: 9px; font-weight: 700; color: var(--red); + background: rgba(255,68,102,0.15); padding: 2px 6px; border-radius: 3px; + } + + .puzzle-obj-item { + display: flex; align-items: center; gap: 10px; + padding: 8px 0; border-bottom: 1px solid var(--border); + font-size: 12px; + } + .puzzle-obj-item:last-child { border-bottom: none; } + .puzzle-obj-star { color: var(--yellow); width: 30px; flex-shrink: 0; } + .puzzle-obj-desc { color: var(--text2); flex: 1; } + + /* --- Level Complete Modal Mobile --- */ + .gm-complete-overlay { padding: 0 16px; } + .gm-complete-card { + min-width: unset; max-width: unset; width: 100%; + padding: 24px 20px; + } + .gm-complete-actions { flex-direction: column; width: 100%; } + .gm-complete-actions .gm-btn { width: 100%; justify-content: center; } + .gm-complete-actions .gm-btn.primary { + order: -1; padding: 14px; + font-size: 13px; font-weight: 700; + } + + /* --- Preset Modal Mobile --- */ + .modal { min-width: unset; max-width: unset; width: calc(100% - 32px); } + + /* --- General touch targets --- */ + .gm-btn { min-height: 44px; display: flex; align-items: center; } + .gm-palette-item { padding: 12px 10px; } + +} From 8b66944e52f6bc158bf5703ce31744bb4a7db0d4 Mon Sep 17 00:00:00 2001 From: Jose Luis Date: Sat, 21 Mar 2026 15:46:28 +0100 Subject: [PATCH 02/22] fix: enable touch panning and prevent page scroll on mobile - Add touch-action: none on canvas to prevent browser scroll hijack - Single-finger touch on empty canvas now triggers pan (pointerType check) - Fix page bounce on mobile with position: fixed and 100dvh height Co-Authored-By: Claude Opus 4.6 (1M context) --- src/App.jsx | 9 ++++++++- src/game/PuzzleView.jsx | 9 ++++++++- src/index.css | 6 +++++- 3 files changed, 21 insertions(+), 3 deletions(-) diff --git a/src/App.jsx b/src/App.jsx index 3e7ef56..b9d8a28 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -91,10 +91,17 @@ export default function App({ onSwitchToGame }) { state.panStart = { x: e.clientX - state.camX, y: e.clientY - state.camY }; e.preventDefault(); } else if (e.button === 0 && !connectingRef.current) { + // On mobile (touch), single finger on empty canvas = pan + if (isMobile && e.pointerType === 'touch') { + state.panning = true; + state.panStart = { x: e.clientX - state.camX, y: e.clientY - state.camY }; + e.preventDefault(); + return; + } state.selectedModuleId = null; emit(); } - }, []); + }, [isMobile]); const handlePointerMove = useCallback((e) => { if (state.panning && state.panStart) { diff --git a/src/game/PuzzleView.jsx b/src/game/PuzzleView.jsx index 3d5b011..0c32da9 100644 --- a/src/game/PuzzleView.jsx +++ b/src/game/PuzzleView.jsx @@ -131,10 +131,17 @@ export default function PuzzleView({ level, levelIndex, worldLevels, onBack, onN state.panStart = { x: e.clientX - state.camX, y: e.clientY - state.camY }; e.preventDefault(); } else if (e.button === 0 && !connectingRef.current) { + // On mobile (touch), single finger on empty canvas = pan + if (isMobile && e.pointerType === 'touch') { + state.panning = true; + state.panStart = { x: e.clientX - state.camX, y: e.clientY - state.camY }; + e.preventDefault(); + return; + } state.selectedModuleId = null; emit(); } - }, []); + }, [isMobile]); const handlePointerMove = useCallback((e) => { if (state.panning && state.panStart) { diff --git a/src/index.css b/src/index.css index 917e088..0e28381 100644 --- a/src/index.css +++ b/src/index.css @@ -802,6 +802,10 @@ html, body, #root { @media (max-width: 768px) { + /* --- Prevent page scroll/bounce --- */ + html, body, #root { overflow: hidden; position: fixed; width: 100%; height: 100%; } + .app, .gm-puzzle { overflow: hidden; height: 100dvh; } + /* --- Sandbox Toolbar --- */ .toolbar { height: 44px; padding: 0 12px; gap: 6px; } .toolbar-title { font-size: 13px; letter-spacing: 0.8px; } @@ -924,7 +928,7 @@ html, body, #root { } /* --- Canvas adjustments --- */ - .node-canvas { cursor: default; } + .node-canvas { cursor: default; touch-action: none; } .zoom-controls { right: 8px; top: 8px; } .zoom-btn { width: 40px; height: 36px; min-height: 44px; } .port-dot { width: 18px; height: 18px; } From 323f30cfb98bfb2e15e1519c8f71340b822d752f Mon Sep 17 00:00:00 2001 From: Jose Luis Date: Sat, 21 Mar 2026 15:53:28 +0100 Subject: [PATCH 03/22] fix: collapsible bottom sheet + pinch-to-zoom on mobile - Bottom sheet starts collapsed (handle bar only), swipe up to expand - Tabs visible when collapsed in puzzle view, content hidden - Swipe down or tap handle to collapse - Add usePinchZoom hook: two-finger pinch gesture controls canvas zoom - Pinch zoom wired into both Sandbox and Puzzle View canvases Co-Authored-By: Claude Opus 4.6 (1M context) --- src/App.jsx | 6 +++++ src/components/BottomSheet.jsx | 25 ++++++++++-------- src/game/PuzzleView.jsx | 6 +++++ src/hooks/usePinchZoom.js | 48 ++++++++++++++++++++++++++++++++++ src/index.css | 15 ++++++++--- 5 files changed, 85 insertions(+), 15 deletions(-) create mode 100644 src/hooks/usePinchZoom.js diff --git a/src/App.jsx b/src/App.jsx index b9d8a28..2d500a9 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -11,6 +11,7 @@ import PresetModal from './components/PresetModal.jsx'; import BottomSheet from './components/BottomSheet.jsx'; import { CHIPTUNE_PRESET } from './presets/chiptune.js'; import { useIsMobile } from './hooks/useIsMobile.js'; +import { usePinchZoom } from './hooks/usePinchZoom.js'; import { getModulesByCategory } from './engine/moduleRegistry.js'; export default function App({ onSwitchToGame }) { @@ -24,6 +25,11 @@ export default function App({ onSwitchToGame }) { const isMobile = useIsMobile(); const [menuOpen, setMenuOpen] = useState(false); + // Pinch-to-zoom on mobile + const getZoom = useCallback(() => state.zoom, []); + const setZoom = useCallback((z) => { state.zoom = z; emit(); }, []); + usePinchZoom(containerRef, getZoom, setZoom); + // Subscribe to state changes useEffect(() => { const unsub = subscribe(() => forceUpdate(n => n + 1)); diff --git a/src/components/BottomSheet.jsx b/src/components/BottomSheet.jsx index 62ad89c..d141cfe 100644 --- a/src/components/BottomSheet.jsx +++ b/src/components/BottomSheet.jsx @@ -3,7 +3,6 @@ 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 sheetRef = useRef(null); const handleTouchStart = useCallback((e) => { startY.current = e.touches[0].clientY; @@ -11,28 +10,30 @@ export default function BottomSheet({ tabs, activeTab, onTabChange, children, cl const handleTouchEnd = useCallback((e) => { const deltaY = e.changedTouches[0].clientY - startY.current; - if (deltaY < -40) setExpanded(true); - if (deltaY > 40) setExpanded(false); + if (deltaY < -30) setExpanded(true); + if (deltaY > 30) setExpanded(false); }, []); return (
-
setExpanded(e => !e)}> +
setExpanded(v => !v)}>
+ {!expanded && !tabs && ( + Modulos โ–ฒ + )}
{tabs && tabs.length > 0 && ( -
+
!expanded && setExpanded(true)}> {tabs.map(tab => (
); } diff --git a/src/game/PuzzleView.jsx b/src/game/PuzzleView.jsx index 0c32da9..8fae2c7 100644 --- a/src/game/PuzzleView.jsx +++ b/src/game/PuzzleView.jsx @@ -6,6 +6,7 @@ import ModuleNode from '../components/ModuleNode.jsx'; import WireLayer from '../components/WireLayer.jsx'; import BottomSheet from '../components/BottomSheet.jsx'; import { useIsMobile } from '../hooks/useIsMobile.js'; +import { usePinchZoom } from '../hooks/usePinchZoom.js'; import { playTarget, stopTarget, isTargetPlaying } from './targetAudio.js'; import LevelComplete from './LevelComplete.jsx'; import { completeLevel, saveLevelPatch, getLevelPatch, clearLevelPatch, markHintUsed, wasHintUsed } from './gameState.js'; @@ -25,6 +26,11 @@ export default function PuzzleView({ level, levelIndex, worldLevels, onBack, onN const isMobile = useIsMobile(); const [mobileTab, setMobileTab] = useState('mission'); + // Pinch-to-zoom on mobile + const getZoom = useCallback(() => state.zoom, []); + const setZoom = useCallback((z) => { state.zoom = z; emit(); }, []); + usePinchZoom(containerRef, getZoom, setZoom); + useEffect(() => { const unsub = subscribe(() => { forceUpdate(n => n + 1); diff --git a/src/hooks/usePinchZoom.js b/src/hooks/usePinchZoom.js new file mode 100644 index 0000000..6aaa3f5 --- /dev/null +++ b/src/hooks/usePinchZoom.js @@ -0,0 +1,48 @@ +import { useRef, useEffect } from 'react'; + +export function usePinchZoom(containerRef, getZoom, setZoom) { + const pinchRef = useRef({ active: false, startDist: 0, startZoom: 1 }); + + useEffect(() => { + const el = containerRef.current; + if (!el) return; + + const getDistance = (t1, t2) => + Math.sqrt((t1.clientX - t2.clientX) ** 2 + (t1.clientY - t2.clientY) ** 2); + + const onTouchStart = (e) => { + if (e.touches.length === 2) { + e.preventDefault(); + pinchRef.current = { + active: true, + startDist: getDistance(e.touches[0], e.touches[1]), + startZoom: getZoom(), + }; + } + }; + + const onTouchMove = (e) => { + if (pinchRef.current.active && e.touches.length === 2) { + e.preventDefault(); + const dist = getDistance(e.touches[0], e.touches[1]); + const scale = dist / pinchRef.current.startDist; + const newZoom = Math.max(0.3, Math.min(3, pinchRef.current.startZoom * scale)); + setZoom(newZoom); + } + }; + + const onTouchEnd = () => { + pinchRef.current.active = false; + }; + + el.addEventListener('touchstart', onTouchStart, { passive: false }); + el.addEventListener('touchmove', onTouchMove, { passive: false }); + el.addEventListener('touchend', onTouchEnd); + + return () => { + el.removeEventListener('touchstart', onTouchStart); + el.removeEventListener('touchmove', onTouchMove); + el.removeEventListener('touchend', onTouchEnd); + }; + }, [containerRef, getZoom, setZoom]); +} diff --git a/src/index.css b/src/index.css index 0e28381..b789ff2 100644 --- a/src/index.css +++ b/src/index.css @@ -873,19 +873,26 @@ html, body, #root { display: flex; flex-direction: column; background: var(--panel); border-top: 1px solid var(--border); border-radius: 16px 16px 0 0; flex-shrink: 0; - max-height: 40vh; transition: max-height 0.3s ease; + transition: max-height 0.3s ease; overflow: hidden; } - .bottom-sheet.expanded { max-height: 60vh; } + .bottom-sheet.collapsed { max-height: 42px; } + .bottom-sheet.collapsed:has(.bottom-sheet-tabs) { max-height: 76px; } + .bottom-sheet.expanded { max-height: 55vh; } .bottom-sheet-handle { - display: flex; justify-content: center; padding: 10px 0 6px; - cursor: grab; + display: flex; align-items: center; justify-content: center; + gap: 8px; padding: 10px 0 6px; cursor: pointer; min-height: 34px; } .bottom-sheet-handle-bar { width: 40px; height: 4px; background: var(--border); border-radius: 2px; } + .bottom-sheet-peek-label { + font-size: 10px; font-weight: 600; color: var(--text2); + font-family: 'JetBrains Mono', monospace; letter-spacing: 1px; + text-transform: uppercase; + } .bottom-sheet-tabs { display: flex; padding: 0 16px; gap: 0; From 816e7270ede304aade011f86ebe704f580e56fb0 Mon Sep 17 00:00:00 2001 From: Jose Luis Date: Sat, 21 Mar 2026 16:02:22 +0100 Subject: [PATCH 04/22] feat: fullscreen keyboard + new Drum Pad module MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Keyboard fullscreen: - Double-tap keyboard widget to enter fullscreen piano mode - 2-octave touch-friendly piano with labeled keys - Active key highlights cyan, close button to exit Drum Pad module (๐Ÿฅ): - New module type with 4x4 colored pad grid - Each pad triggers a unique frequency (C2-D4 range) - Outputs freq + gate signals (same as keyboard) - Double-tap for fullscreen pad mode with large touch targets - Color-coded pads with hit animation Co-Authored-By: Claude Opus 4.6 (1M context) --- src/components/DrumPadWidget.jsx | 122 +++++++++++++++++++++++ src/components/KeyboardWidget.jsx | 156 +++++++++++++++++++++++++----- src/components/ModuleNode.jsx | 4 + src/engine/audioEngine.js | 6 +- src/engine/moduleRegistry.js | 14 +++ src/index.css | 93 ++++++++++++++++++ 6 files changed, 368 insertions(+), 27 deletions(-) create mode 100644 src/components/DrumPadWidget.jsx 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 ============================================ */ From 892195410bccde47a242da2c2f4831562c5a4677 Mon Sep 17 00:00:00 2001 From: Jose Luis Date: Sat, 21 Mar 2026 16:08:10 +0100 Subject: [PATCH 05/22] fix: proper fullscreen piano (1 octave, big keys) + block native zoom MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fullscreen piano redesign: - 1 octave with 7 large white keys filling the entire screen - Gradient-lit keys with cyan press highlight - Octave navigation buttons (โ—€ โ–ถ) to shift up/down - Note labels on each key (C4, D4, etc.) - Black keys proportionally sized at 58% height - touch-action: none to prevent any browser interference Block native browser zoom: - viewport meta: maximum-scale=1.0, user-scalable=no - html touch-action: manipulation (prevents double-tap zoom on Safari) Co-Authored-By: Claude Opus 4.6 (1M context) --- index.html | 2 +- src/components/KeyboardWidget.jsx | 116 +++++++++++++++--------------- src/index.css | 60 ++++++++++++---- 3 files changed, 102 insertions(+), 76 deletions(-) diff --git a/index.html b/index.html index 903f633..10f01cf 100644 --- a/index.html +++ b/index.html @@ -2,7 +2,7 @@ - + Reaktor โ€” MontLab Modular Synth diff --git a/src/components/KeyboardWidget.jsx b/src/components/KeyboardWidget.jsx index ac98edd..99dcbda 100644 --- a/src/components/KeyboardWidget.jsx +++ b/src/components/KeyboardWidget.jsx @@ -16,84 +16,80 @@ 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()); +// 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 playNote = useCallback((semitone) => { - const midi = (octave + 1) * 12 + semitone; + const play = useCallback((semitone) => { + const midi = (oct + 1) * 12 + semitone; triggerKeyboard(moduleId, midiToFreq(midi), true); - }, [moduleId, octave]); + setActiveNotes(prev => new Set(prev).add(semitone)); + }, [moduleId, oct]); - const stopNote = useCallback(() => { - triggerKeyboard(moduleId, 440, false); + 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]); - 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 }, + // 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' }, ]; - const totalWhites = whites.length; - const keyW = 100 / totalWhites; + // 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 (
- ๐ŸŽน Piano โ€” Oct {octave} + + Octave {oct} + +
- {/* White keys */} - {whites.map((k, i) => ( + {whiteKeys.map((k, i) => (
handleTouch(k.note, true)} - onPointerUp={() => handleTouch(k.note, false)} - onPointerLeave={() => handleTouch(k.note, false)} + key={k.note} + className={`keyboard-fs-white ${activeNotes.has(k.note) ? 'pressed' : ''}`} + onPointerDown={() => play(k.note)} + onPointerUp={() => stop(k.note)} + onPointerLeave={() => stop(k.note)} + onPointerCancel={() => stop(k.note)} > - - {NOTE_NAMES[k.note % 12]} - + {k.name}{oct}
))} - {/* Black keys */} - {blackPositions.map((bp, i) => ( + {blackKeys.map((k) => (
handleTouch(bp.note, true)} - onPointerUp={() => handleTouch(bp.note, false)} - onPointerLeave={() => handleTouch(bp.note, false)} - /> + key={k.note} + className={`keyboard-fs-black ${activeNotes.has(k.note) ? 'pressed' : ''}`} + style={{ left: `${(k.after + 0.65) * (100 / 7)}%`, width: `${(100 / 7) * 0.65}%` }} + onPointerDown={() => play(k.note)} + onPointerUp={() => stop(k.note)} + onPointerLeave={() => stop(k.note)} + onPointerCancel={() => stop(k.note)} + > + {k.name} +
))}
@@ -188,7 +184,7 @@ export default function KeyboardWidget({ moduleId }) { {fullscreen && ( setFullscreen(false)} /> )} diff --git a/src/index.css b/src/index.css index 87ebc40..5e4f1dc 100644 --- a/src/index.css +++ b/src/index.css @@ -30,7 +30,11 @@ html, body, #root { font-family: 'Inter', 'SF Pro', -apple-system, system-ui, sans-serif; font-size: 13px; -webkit-font-smoothing: antialiased; + touch-action: pan-x pan-y; + -ms-touch-action: pan-x pan-y; } +/* Block native browser zoom gestures globally */ +html { touch-action: manipulation; } /* ===== Layout ===== */ .app { display: flex; flex-direction: column; height: 100vh; } @@ -790,46 +794,72 @@ html, body, #root { .keyboard-fullscreen { position: fixed; inset: 0; z-index: 500; background: #050510; display: flex; flex-direction: column; - animation: fadeIn 0.2s ease-out; + animation: fadeIn 0.2s ease-out; touch-action: none; } @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); + display: flex; align-items: center; justify-content: center; + gap: 16px; padding: 8px 16px; background: var(--panel); + border-bottom: 1px solid var(--border); flex-shrink: 0; +} +.keyboard-fs-title { + font-size: 16px; font-weight: 700; color: var(--text); + font-family: 'JetBrains Mono', monospace; letter-spacing: 1px; } -.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-oct-btn { + width: 44px; height: 36px; border-radius: 8px; + background: var(--surface); border: 1px solid var(--border); + color: var(--accent); font-size: 16px; cursor: pointer; + display: flex; align-items: center; justify-content: center; +} +.keyboard-fs-oct-btn:active { background: var(--accent); color: #000; } .keyboard-fs-keys { flex: 1; position: relative; display: flex; - padding: 0; touch-action: none; user-select: none; + touch-action: none; user-select: none; } .keyboard-fs-white { - flex-shrink: 0; height: 100%; background: #1a1a2e; - border-right: 1px solid #252545; position: relative; + flex: 1; height: 100%; background: linear-gradient(to bottom, #1e1e38, #14142a); + border-right: 2px solid #0a0a18; position: relative; display: flex; align-items: flex-end; justify-content: center; - padding-bottom: 16px; cursor: pointer; transition: background 0.05s; + padding-bottom: 20px; cursor: pointer; transition: background 0.05s; +} +.keyboard-fs-white.pressed, .keyboard-fs-white:active { + background: linear-gradient(to bottom, var(--accent), #0a8a9e); } -.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; + font-size: 14px; font-weight: 600; color: var(--text2); + font-family: 'JetBrains Mono', monospace; pointer-events: none; } +.keyboard-fs-white.pressed .keyboard-fs-note-label, .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; + position: absolute; top: 0; height: 58%; + background: linear-gradient(to bottom, #0a0a16, #060610); + border: 2px solid #222; border-top: none; + border-radius: 0 0 6px 6px; z-index: 2; cursor: pointer; transition: background 0.05s; + display: flex; align-items: flex-end; justify-content: center; + padding-bottom: 8px; } -.keyboard-fs-black:active { background: var(--accent); } +.keyboard-fs-black.pressed, .keyboard-fs-black:active { + background: linear-gradient(to bottom, #0088aa, #006688); + border-color: var(--accent); +} +.keyboard-fs-black-label { + font-size: 10px; font-weight: 600; color: #555; + font-family: 'JetBrains Mono', monospace; pointer-events: none; +} +.keyboard-fs-black.pressed .keyboard-fs-black-label, +.keyboard-fs-black:active .keyboard-fs-black-label { color: var(--accent); } /* ===== Drum Pad ===== */ .drumpad-grid { From f0e7f7f37a0d5506b37acde1aecd0ae093b5dc27 Mon Sep 17 00:00:00 2001 From: Jose Luis Date: Sat, 21 Mar 2026 16:11:32 +0100 Subject: [PATCH 06/22] fix: expand button for fullscreen + disable text selection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace double-tap trigger with โคข expand button in module header for keyboard and drumpad modules (more reliable, no text selection) - Disable user-select globally (except inputs/textareas) - Fullscreen state managed in ModuleNode, passed to widgets as props - Remove unused imports (useIsMobile, useRef) from widgets Co-Authored-By: Claude Opus 4.6 (1M context) --- src/components/DrumPadWidget.jsx | 25 +++++-------------------- src/components/KeyboardWidget.jsx | 22 ++++------------------ src/components/ModuleNode.jsx | 12 ++++++++++-- src/index.css | 11 +++++++++++ 4 files changed, 30 insertions(+), 40 deletions(-) diff --git a/src/components/DrumPadWidget.jsx b/src/components/DrumPadWidget.jsx index 5098b67..ed54575 100644 --- a/src/components/DrumPadWidget.jsx +++ b/src/components/DrumPadWidget.jsx @@ -1,7 +1,5 @@ -import React, { useCallback, useRef, useState } from 'react'; +import React, { useCallback, 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 = [ @@ -66,11 +64,8 @@ function FullscreenDrumPad({ moduleId, onClose }) { ); } -export default function DrumPadWidget({ moduleId }) { - const isMobile = useIsMobile(); - const [fullscreen, setFullscreen] = useState(false); +export default function DrumPadWidget({ moduleId, fullscreen, onCloseFullscreen }) { const [activePad, setActivePad] = useState(-1); - const lastTap = useRef(0); const hitPad = useCallback((pad, idx) => { triggerKeyboard(moduleId, midiToFreq(pad.note), true); @@ -81,19 +76,9 @@ export default function DrumPadWidget({ moduleId }) { }, 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) => (
- {isMobile ? 'Doble-tap: pantalla completa' : 'Tap pads to trigger'} + Tap pads to trigger
{fullscreen && ( - setFullscreen(false)} /> + )} ); diff --git a/src/components/KeyboardWidget.jsx b/src/components/KeyboardWidget.jsx index 99dcbda..8329fd4 100644 --- a/src/components/KeyboardWidget.jsx +++ b/src/components/KeyboardWidget.jsx @@ -1,7 +1,6 @@ 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']; @@ -96,13 +95,10 @@ function FullscreenPiano({ moduleId, initialOctave, onClose }) { ); } -export default function KeyboardWidget({ moduleId }) { +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()); - const isMobile = useIsMobile(); - const [fullscreen, setFullscreen] = useState(false); - const lastTap = useRef(0); const playNote = useCallback((semitone) => { const midi = (octave + 1) * 12 + semitone; @@ -138,23 +134,13 @@ export default function KeyboardWidget({ moduleId }) { }; }, [playNote, stopNote]); - 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) => (
- {isMobile ? 'Doble-tap: pantalla completa' : 'Z-M / Q-I keys'} ยท Oct {octave} + Z-M / Q-I keys ยท Oct {octave}
@@ -185,7 +171,7 @@ export default function KeyboardWidget({ moduleId }) { setFullscreen(false)} + onClose={onCloseFullscreen} /> )} diff --git a/src/components/ModuleNode.jsx b/src/components/ModuleNode.jsx index 4f006ef..400dcb7 100644 --- a/src/components/ModuleNode.jsx +++ b/src/components/ModuleNode.jsx @@ -46,6 +46,7 @@ export default function ModuleNode({ mod, zoom, onStartConnect, onPortPosition } if (!def) return null; const isSelected = state.selectedModuleId === mod.id; + const [fullscreen, setFullscreen] = useState(false); // Merge default params const params = { ...Object.fromEntries(Object.entries(def.params).map(([k, v]) => [k, v.default])), ...mod.params }; @@ -177,6 +178,13 @@ export default function ModuleNode({ mod, zoom, onStartConnect, onPortPosition }
{def.icon} {def.name} + {(mod.type === 'keyboard' || mod.type === 'drumpad') && ( + + )}
@@ -247,10 +255,10 @@ export default function ModuleNode({ mod, zoom, onStartConnect, onPortPosition } {mod.type === 'scope' && } {/* Keyboard widget */} - {mod.type === 'keyboard' && } + {mod.type === 'keyboard' && setFullscreen(false)} />} {/* Drum Pad widget */} - {mod.type === 'drumpad' && } + {mod.type === 'drumpad' && setFullscreen(false)} />} {/* Sequencer widget */} {mod.type === 'sequencer' && } diff --git a/src/index.css b/src/index.css index 5e4f1dc..1a18d20 100644 --- a/src/index.css +++ b/src/index.css @@ -35,6 +35,9 @@ html, body, #root { } /* Block native browser zoom gestures globally */ html { touch-action: manipulation; } +/* Prevent text selection globally (except inputs/textareas) */ +* { -webkit-user-select: none; user-select: none; } +input, textarea, [contenteditable] { -webkit-user-select: text; user-select: text; } /* ===== Layout ===== */ .app { display: flex; flex-direction: column; height: 100vh; } @@ -121,6 +124,14 @@ html { touch-action: manipulation; } } .module-header .close-btn:hover { background: var(--red); color: #fff; } +.module-header .expand-btn { + width: 18px; height: 18px; border: none; background: transparent; + color: var(--text2); cursor: pointer; font-size: 13px; border-radius: 3px; + display: flex; align-items: center; justify-content: center; + margin-left: auto; +} +.module-header .expand-btn:hover { background: var(--accent); color: #000; } + .module-body { padding: 10px 12px; display: flex; flex-direction: column; gap: 8px; } /* Ports */ From 8b193126f75e8da751a306ebb32b95abf06ba1b9 Mon Sep 17 00:00:00 2001 From: Jose Luis Date: Sat, 21 Mar 2026 16:15:29 +0100 Subject: [PATCH 07/22] fix: render fullscreen overlays via React Portal to document.body The fullscreen piano/drumpad was rendering inside ModuleNode which has CSS transform: scale(zoom). This breaks position: fixed (fixed elements inside a transformed parent position relative to the transform, not the viewport). Using createPortal to document.body fixes this. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/components/DrumPadWidget.jsx | 6 ++++-- src/components/KeyboardWidget.jsx | 6 ++++-- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/src/components/DrumPadWidget.jsx b/src/components/DrumPadWidget.jsx index ed54575..70be250 100644 --- a/src/components/DrumPadWidget.jsx +++ b/src/components/DrumPadWidget.jsx @@ -1,4 +1,5 @@ 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 @@ -99,8 +100,9 @@ export default function DrumPadWidget({ moduleId, fullscreen, onCloseFullscreen
- {fullscreen && ( - + {fullscreen && createPortal( + , + document.body )} ); diff --git a/src/components/KeyboardWidget.jsx b/src/components/KeyboardWidget.jsx index 8329fd4..63cc5f2 100644 --- a/src/components/KeyboardWidget.jsx +++ b/src/components/KeyboardWidget.jsx @@ -1,4 +1,5 @@ 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'; @@ -167,12 +168,13 @@ export default function KeyboardWidget({ moduleId, fullscreen, onCloseFullscreen
- {fullscreen && ( + {fullscreen && createPortal( + />, + document.body )} ); From 52045897e5524bd90dd93295f16c84f46877c4e9 Mon Sep 17 00:00:00 2001 From: Jose Luis Date: Sat, 21 Mar 2026 16:24:33 +0100 Subject: [PATCH 08/22] feat: add PWA support (installable app) - Web app manifest with name, icons, theme color, standalone display - Service worker with stale-while-revalidate caching strategy - 192px and 512px PNG icons generated from favicon.svg - Apple-specific meta tags for iOS home screen support - Register service worker on page load Co-Authored-By: Claude Opus 4.6 (1M context) --- index.html | 5 +++++ public/icon-192.png | Bin 0 -> 3589 bytes public/icon-512.png | Bin 0 -> 11173 bytes public/manifest.json | 15 +++++++++++++++ public/sw.js | 33 +++++++++++++++++++++++++++++++++ src/main.jsx | 7 +++++++ 6 files changed, 60 insertions(+) create mode 100644 public/icon-192.png create mode 100644 public/icon-512.png create mode 100644 public/manifest.json create mode 100644 public/sw.js diff --git a/index.html b/index.html index 10f01cf..56f0f0c 100644 --- a/index.html +++ b/index.html @@ -5,6 +5,11 @@ Reaktor โ€” MontLab Modular Synth + + + + +
diff --git a/public/icon-192.png b/public/icon-192.png new file mode 100644 index 0000000000000000000000000000000000000000..723ed12371feac344293ae6578f4b8493ffeb8d0 GIT binary patch literal 3589 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 literal 0 HcmV?d00001 diff --git a/public/icon-512.png b/public/icon-512.png new file mode 100644 index 0000000000000000000000000000000000000000..2de1180ab8ee646742776ea43a7d57434c5083f3 GIT binary patch literal 11173 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 { + self.skipWaiting(); +}); + +self.addEventListener('activate', (e) => { + e.waitUntil( + caches.keys().then(keys => + Promise.all(keys.filter(k => k !== CACHE_NAME).map(k => caches.delete(k))) + ) + ); + self.clients.claim(); +}); + +self.addEventListener('fetch', (e) => { + // Only cache GET requests, skip API calls + if (e.request.method !== 'GET') return; + + e.respondWith( + caches.match(e.request).then(cached => { + const fetching = fetch(e.request).then(response => { + if (response.ok) { + const clone = response.clone(); + caches.open(CACHE_NAME).then(cache => cache.put(e.request, clone)); + } + return response; + }).catch(() => cached); + + return cached || fetching; + }) + ); +}); diff --git a/src/main.jsx b/src/main.jsx index fadf959..c7427ce 100644 --- a/src/main.jsx +++ b/src/main.jsx @@ -15,3 +15,10 @@ function Root() { } createRoot(document.getElementById('root')).render(); + +// Register service worker for PWA +if ('serviceWorker' in navigator) { + window.addEventListener('load', () => { + navigator.serviceWorker.register('/sw.js').catch(() => {}); + }); +} From cf6e9129053539a71e001f4eca30e32c9b35fff4 Mon Sep 17 00:00:00 2001 From: Jose Luis Date: Sat, 21 Mar 2026 16:31:12 +0100 Subject: [PATCH 09/22] fix: auto-center viewport when puzzle level loads Call handleCenterView after level load with a short delay to let the DOM settle, so modules are centered on screen on both mobile and desktop. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/game/PuzzleView.jsx | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/game/PuzzleView.jsx b/src/game/PuzzleView.jsx index 8fae2c7..51c5c31 100644 --- a/src/game/PuzzleView.jsx +++ b/src/game/PuzzleView.jsx @@ -59,7 +59,10 @@ export default function PuzzleView({ level, levelIndex, worldLevels, onBack, onN useEffect(() => { loadLevel(); + // Center view on modules after level loads and DOM settles + const timer = setTimeout(() => handleCenterView(), 100); return () => { + clearTimeout(timer); stopAudio(); stopTarget(); }; From 1cf39f9b13a489c4cf61335c15b80c9ec9837f6c Mon Sep 17 00:00:00 2001 From: Jose Luis Date: Sat, 21 Mar 2026 17:28:55 +0100 Subject: [PATCH 10/22] fix: unlock audio context on first user interaction UI sounds weren't playing until the user hit Play because Tone.js AudioContext was suspended. Now Tone.start() is called on the first pointerdown or keydown event, so UI sounds work immediately. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/main.jsx | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/main.jsx b/src/main.jsx index c7427ce..2ca5599 100644 --- a/src/main.jsx +++ b/src/main.jsx @@ -16,6 +16,18 @@ function Root() { createRoot(document.getElementById('root')).render(); +// Unlock audio context on first user interaction +import * as Tone from 'tone'; +const unlockAudio = () => { + if (Tone.context.state !== 'running') { + Tone.start().catch(() => {}); + } + document.removeEventListener('pointerdown', unlockAudio); + document.removeEventListener('keydown', unlockAudio); +}; +document.addEventListener('pointerdown', unlockAudio); +document.addEventListener('keydown', unlockAudio); + // Register service worker for PWA if ('serviceWorker' in navigator) { window.addEventListener('load', () => { From b91b35f23d123dbcf4c20feaf8644a559eb82a61 Mon Sep 17 00:00:00 2001 From: Jose Luis Date: Sat, 21 Mar 2026 17:35:41 +0100 Subject: [PATCH 11/22] fix: eliminate audio timing jitter and rhythm drift MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root causes fixed: - Sequencer: replaced setTimeout note-off with Tone.Transport.scheduleOnce for sample-accurate timing instead of main-thread-dependent setTimeout - Sequencer + PianoRoll: decoupled visual updates from audio callbacks. Audio clock only writes to refs, RAF loop reads refs for visual step indicator. No more React setState inside Tone.Clock callbacks. - audioEngine: added connection lookup cache (Map) to replace O(nยฒ) array iterations in setSequencerSignals/triggerKeyboard. Cache rebuilds lazily only when connections change. These changes eliminate the feedback loop where: audio callback โ†’ setState โ†’ React render โ†’ main thread blocks โ†’ setTimeout delayed โ†’ note-off late โ†’ drift compounds Co-Authored-By: Claude Opus 4.6 (1M context) --- src/components/PianoRollWidget.jsx | 25 +++++------ src/components/SequencerWidget.jsx | 63 +++++++++++++++----------- src/engine/audioEngine.js | 72 +++++++++++++++++------------- 3 files changed, 88 insertions(+), 72 deletions(-) diff --git a/src/components/PianoRollWidget.jsx b/src/components/PianoRollWidget.jsx index 9e22f36..f612d50 100644 --- a/src/components/PianoRollWidget.jsx +++ b/src/components/PianoRollWidget.jsx @@ -91,10 +91,10 @@ export default function PianoRollWidget({ moduleId }) { const mod = state.modules.find(m => m.id === moduleId); const canvasRef = useRef(null); const partRef = useRef(null); - const [playPos, setPlayPos] = useState(-1); const [tool, setTool] = useState('draw'); // 'draw' | 'erase' const drawingRef = useRef(null); const rafRef = useRef(null); + const playPosRef = useRef(-1); const midiInputRef = useRef(null); const bpm = mod?.params?.bpm ?? 140; @@ -196,8 +196,9 @@ export default function PianoRollWidget({ moduleId }) { } // Playhead - if (playPos >= 0 && playPos < totalBeats) { - const px = KEY_W + playPos * beatW; + const currentPlayPos = playPosRef.current; + if (currentPlayPos >= 0 && currentPlayPos < totalBeats) { + const px = KEY_W + currentPlayPos * beatW; ctx.strokeStyle = '#ff6644'; ctx.lineWidth = 2; ctx.beginPath(); @@ -221,7 +222,7 @@ export default function PianoRollWidget({ moduleId }) { ctx.fillStyle = 'rgba(0,229,255,0.3)'; ctx.fillRect(x, row * ROW_H, Math.max(nw, 2), ROW_H); } - }, [totalBeats, beatW, playPos, rollW]); + }, [totalBeats, beatW, rollW]); // Animation loop useEffect(() => { @@ -242,7 +243,7 @@ export default function PianoRollWidget({ moduleId }) { try { partRef.current.dispose(); } catch {} partRef.current = null; } - setPlayPos(-1); + playPosRef.current = -1; return; } @@ -252,46 +253,40 @@ export default function PianoRollWidget({ moduleId }) { let currentNote = null; // track currently sounding note for on/off transitions const clock = new Tone.Clock(() => { - const rawPos = tickCount * 0.25; // position in beats (each tick = 1 sixteenth = 0.25 beats) + const rawPos = tickCount * 0.25; const pos = loop ? rawPos % totalBeats : rawPos; const prevRawPos = (tickCount - 1) * 0.25; const prevPos = loop ? prevRawPos % totalBeats : prevRawPos; tickCount++; - // Detect loop wrap (position jumped backwards) const looped = tickCount > 1 && pos < prevPos; - // Stop at end if not looping if (!loop && rawPos >= totalBeats) { if (currentNote) { setSequencerSignals(moduleId, 0, false); currentNote = null; } - setPlayPos(-1); + playPosRef.current = -1; return; } - setPlayPos(pos); + // Update ref, not state โ€” visual follows via RAF + playPosRef.current = pos; - // Force note-off on loop boundary for clean retrigger if (looped && currentNote) { setSequencerSignals(moduleId, 0, false); currentNote = null; } - // Find the note active at this position const allNotes = notesRef.current; const activeNote = allNotes.find(n => pos >= n.start && pos < n.start + n.duration); if (activeNote) { - // New note or different note โ†’ trigger if (!currentNote || currentNote.note !== activeNote.note || currentNote.start !== activeNote.start) { setSequencerSignals(moduleId, midiToFreq(activeNote.note), true); currentNote = activeNote; } - // Same note sustaining โ†’ do nothing } else { - // No note at this position โ†’ gate off if (currentNote) { setSequencerSignals(moduleId, 0, false); currentNote = null; diff --git a/src/components/SequencerWidget.jsx b/src/components/SequencerWidget.jsx index 437a5b8..3a15bbf 100644 --- a/src/components/SequencerWidget.jsx +++ b/src/components/SequencerWidget.jsx @@ -8,7 +8,6 @@ const NOTE_NAMES = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', function midiToFreq(midi) { return 440 * Math.pow(2, (midi - 69) / 12); } function noteLabel(midi) { return NOTE_NAMES[midi % 12] + Math.floor(midi / 12 - 1); } -// Default notes: C minor pentatonic pattern const DEFAULT_STEPS = [ { midi: 60, gate: true }, { midi: 63, gate: true }, { midi: 65, gate: false }, { midi: 67, gate: true }, { midi: 70, gate: true }, { midi: 67, gate: true }, { midi: 63, gate: false }, { midi: 60, gate: true }, @@ -18,11 +17,13 @@ const DEFAULT_STEPS = [ export default function SequencerWidget({ moduleId }) { const mod = state.modules.find(m => m.id === moduleId); - const [currentStep, setCurrentStep] = useState(-1); + const currentStepRef = useRef(-1); + const [visualStep, setVisualStep] = useState(-1); const clockRef = useRef(null); const stepsRef = useRef(null); + const rafRef = useRef(null); - // Init steps data โ€” also grow/shrink when numSteps changes + // Init steps data const numSteps = parseInt(mod?.params?.steps || '16'); if (mod) { if (!mod.params._steps) { @@ -30,12 +31,10 @@ export default function SequencerWidget({ moduleId }) { while (initial.length < numSteps) initial.push({ midi: 60, gate: false }); mod.params._steps = initial; } else if (mod.params._steps.length < numSteps) { - // Grow: pad with empty steps while (mod.params._steps.length < numSteps) { mod.params._steps.push({ midi: 60, gate: false }); } } else if (mod.params._steps.length > numSteps) { - // Shrink: truncate mod.params._steps = mod.params._steps.slice(0, numSteps); } } @@ -44,8 +43,21 @@ export default function SequencerWidget({ moduleId }) { const bpm = mod?.params?.bpm ?? 140; - // Start/stop sequencer when audio engine runs โ€” uses independent Tone.Clock - // so multiple sequencers don't interfere with each other via the global Transport + // Visual update loop โ€” decoupled from audio, uses RAF + useEffect(() => { + const tick = () => { + setVisualStep(currentStepRef.current); + rafRef.current = requestAnimationFrame(tick); + }; + if (state.isRunning) { + rafRef.current = requestAnimationFrame(tick); + } + return () => { + if (rafRef.current) cancelAnimationFrame(rafRef.current); + }; + }, [state.isRunning]); + + // Audio clock โ€” ONLY does audio work, no React state updates useEffect(() => { if (!state.isRunning) { if (clockRef.current) { @@ -53,13 +65,15 @@ export default function SequencerWidget({ moduleId }) { try { clockRef.current.dispose(); } catch {} clockRef.current = null; } - setCurrentStep(-1); + currentStepRef.current = -1; + setVisualStep(-1); return; } - // Independent clock at 16th-note rate - const sixteenthRate = (bpm * 4) / 60; // Hz + const sixteenthRate = (bpm * 4) / 60; + const stepDuration = 1 / sixteenthRate; let step = 0; + let noteOffId = null; const clock = new Tone.Clock((time) => { const stepIdx = step % numSteps; @@ -67,15 +81,19 @@ export default function SequencerWidget({ moduleId }) { const s = stepsRef.current[stepIdx]; if (!s) return; - setCurrentStep(stepIdx); + // Update ref (not state!) โ€” visual follows via RAF + currentStepRef.current = stepIdx; if (s.gate) { setSequencerSignals(moduleId, midiToFreq(s.midi), true); - // Schedule note-off at 80% of step duration - const stepDuration = 1 / sixteenthRate; - setTimeout(() => { + // Schedule note-off using Tone.Draw or Tone.context + // Use the audio clock's time for precise scheduling + if (noteOffId !== null) { + try { Tone.getTransport().clear(noteOffId); } catch {} + } + noteOffId = Tone.getTransport().scheduleOnce(() => { setSequencerSignals(moduleId, midiToFreq(s.midi), false); - }, stepDuration * 0.8 * 1000); + }, time + stepDuration * 0.8); } else { setSequencerSignals(moduleId, midiToFreq(s.midi), false); } @@ -115,20 +133,17 @@ export default function SequencerWidget({ moduleId }) { return (

- {/* Steps */} {steps.slice(0, numSteps).map((s, i) => { const x = i * CELL_W; - const isActive = i === currentStep; + const isActive = i === visualStep; const barHeight = ((s.midi - 36) / 60) * (CELL_H - 4); return ( - {/* Background */} - {/* Note bar */} {s.gate && ( )} - {/* Inactive marker */} {!s.gate && ( )} - {/* Note name */} {noteLabel(s.midi)} - {/* Click areas: top half = note up, bottom half = note down, middle = toggle gate */} changeNote(i, 1)} @@ -162,11 +174,10 @@ export default function SequencerWidget({ moduleId }) { ); })} - {/* Playhead line */} - {currentStep >= 0 && ( + {visualStep >= 0 && ( )} diff --git a/src/engine/audioEngine.js b/src/engine/audioEngine.js index 0cf9f4d..b4d9262 100644 --- a/src/engine/audioEngine.js +++ b/src/engine/audioEngine.js @@ -363,31 +363,46 @@ export function updateParam(moduleId, paramName, value) { } } +// Cache connection lookups for hot-path audio scheduling +// Rebuilt lazily when connections change +let _connCacheVersion = -1; +const _connByModulePort = new Map(); // "moduleId-portName" โ†’ [connections] + +function getConnectionsFrom(moduleId, portName) { + // Rebuild cache if connections changed + const version = state.connections.length + state.connections.reduce((s, c) => s + c.id, 0); + if (version !== _connCacheVersion) { + _connByModulePort.clear(); + for (const conn of state.connections) { + const key = `${conn.from.moduleId}-${conn.from.port}`; + if (!_connByModulePort.has(key)) _connByModulePort.set(key, []); + _connByModulePort.get(key).push(conn); + } + _connCacheVersion = version; + } + return _connByModulePort.get(`${moduleId}-${portName}`) || []; +} + export function setSequencerSignals(moduleId, freq, gate) { const entry = audioNodes[moduleId]; if (!entry) return; if (entry._freqSig) entry._freqSig.value = freq; if (entry._gateSig) entry._gateSig.value = gate ? 1 : 0; - // Directly set connected oscillator frequencies (bypasses the modulation Gain) - for (const conn of state.connections) { - if (conn.from.moduleId === moduleId && conn.from.port === 'freq') { - const oscEntry = audioNodes[conn.to.moduleId]; - const oscMod = state.modules.find(m => m.id === conn.to.moduleId); - if (oscEntry?.node && oscMod?.type === 'oscillator') { - oscEntry.node.frequency.value = freq; - } + // Set connected oscillator frequencies directly + for (const conn of getConnectionsFrom(moduleId, 'freq')) { + const oscEntry = audioNodes[conn.to.moduleId]; + if (oscEntry?.node?.frequency) { + oscEntry.node.frequency.value = freq; } } // Trigger connected envelopes - for (const conn of state.connections) { - if (conn.from.moduleId === moduleId && conn.from.port === 'gate') { - const envEntry = audioNodes[conn.to.moduleId]; - if (envEntry && envEntry.node instanceof Tone.Envelope) { - if (gate) envEntry.node.triggerAttack(); - else envEntry.node.triggerRelease(); - } + for (const conn of getConnectionsFrom(moduleId, 'gate')) { + const envEntry = audioNodes[conn.to.moduleId]; + if (envEntry && envEntry.node instanceof Tone.Envelope) { + if (gate) envEntry.node.triggerAttack(); + else envEntry.node.triggerRelease(); } } } @@ -398,25 +413,20 @@ export function triggerKeyboard(moduleId, freq, gate) { if (entry._freqSig) entry._freqSig.value = freq; if (entry._gateSig) entry._gateSig.value = gate ? 1 : 0; - // Directly set connected oscillator frequencies (bypasses the modulation Gain) - for (const conn of state.connections) { - if (conn.from.moduleId === moduleId && conn.from.port === 'freq') { - const oscEntry = audioNodes[conn.to.moduleId]; - const oscMod = state.modules.find(m => m.id === conn.to.moduleId); - if (oscEntry?.node && oscMod?.type === 'oscillator') { - oscEntry.node.frequency.value = freq; - } + // Set connected oscillator frequencies directly + for (const conn of getConnectionsFrom(moduleId, 'freq')) { + const oscEntry = audioNodes[conn.to.moduleId]; + if (oscEntry?.node?.frequency) { + oscEntry.node.frequency.value = freq; } } - // Also trigger any connected envelopes - for (const conn of state.connections) { - if (conn.from.moduleId === moduleId && conn.from.port === 'gate') { - const envEntry = audioNodes[conn.to.moduleId]; - if (envEntry && envEntry.node instanceof Tone.Envelope) { - if (gate) envEntry.node.triggerAttack(); - else envEntry.node.triggerRelease(); - } + // Trigger connected envelopes + for (const conn of getConnectionsFrom(moduleId, 'gate')) { + const envEntry = audioNodes[conn.to.moduleId]; + if (envEntry && envEntry.node instanceof Tone.Envelope) { + if (gate) envEntry.node.triggerAttack(); + else envEntry.node.triggerRelease(); } } } From 9dba156961cce6f95dc48f81383d4c67ca94756e Mon Sep 17 00:00:00 2001 From: Jose Luis Date: Sat, 21 Mar 2026 17:42:30 +0100 Subject: [PATCH 12/22] fix: eliminate multi-sequencer drift with time-based step calculation Problem: two sequencers at different BPMs (e.g. 80 and 160) would drift apart over time because each used an independent step counter (step++) that accumulated floating-point rounding errors. Fix: derive step/position from audio clock time (Tone.now()), not from an incrementing counter. Step = floor(elapsed * rate) % numSteps. This makes timing mathematically exact regardless of how long it runs. Also: - Sequencer note-off uses Tone.getContext().setTimeout() (audio-thread) instead of Tone.Transport.scheduleOnce() which needs Transport running - Clock runs at 2x rate for tighter step edge detection - PianoRoll uses same time-based position calculation Co-Authored-By: Claude Opus 4.6 (1M context) --- src/components/PianoRollWidget.jsx | 29 +++++++++++++++------------- src/components/SequencerWidget.jsx | 31 +++++++++++++++--------------- 2 files changed, 32 insertions(+), 28 deletions(-) diff --git a/src/components/PianoRollWidget.jsx b/src/components/PianoRollWidget.jsx index f612d50..73474a5 100644 --- a/src/components/PianoRollWidget.jsx +++ b/src/components/PianoRollWidget.jsx @@ -247,19 +247,23 @@ export default function PianoRollWidget({ moduleId }) { return; } - const sixteenthRate = (bpm * 4) / 60; // Hz: sixteenth notes per second - const sixteenthDur = 1 / sixteenthRate; // seconds per sixteenth note - let tickCount = 0; - let currentNote = null; // track currently sounding note for on/off transitions + const sixteenthRate = (bpm * 4) / 60; + const startTime = Tone.now(); + let currentNote = null; + let lastPos = -1; - const clock = new Tone.Clock(() => { - const rawPos = tickCount * 0.25; + const clock = new Tone.Clock((time) => { + // Derive position from audio clock โ€” no counter drift + const elapsed = time - startTime; + const rawPos = elapsed * sixteenthRate * 0.25; // in beats const pos = loop ? rawPos % totalBeats : rawPos; - const prevRawPos = (tickCount - 1) * 0.25; - const prevPos = loop ? prevRawPos % totalBeats : prevRawPos; - tickCount++; - const looped = tickCount > 1 && pos < prevPos; + // Quantize to sixteenth resolution for consistent note detection + const quantPos = Math.floor(pos * 4) / 4; + + // Detect loop wrap + const looped = lastPos >= 0 && quantPos < lastPos; + lastPos = quantPos; if (!loop && rawPos >= totalBeats) { if (currentNote) { @@ -270,7 +274,6 @@ export default function PianoRollWidget({ moduleId }) { return; } - // Update ref, not state โ€” visual follows via RAF playPosRef.current = pos; if (looped && currentNote) { @@ -279,7 +282,7 @@ export default function PianoRollWidget({ moduleId }) { } const allNotes = notesRef.current; - const activeNote = allNotes.find(n => pos >= n.start && pos < n.start + n.duration); + const activeNote = allNotes.find(n => quantPos >= n.start && quantPos < n.start + n.duration); if (activeNote) { if (!currentNote || currentNote.note !== activeNote.note || currentNote.start !== activeNote.start) { @@ -292,7 +295,7 @@ export default function PianoRollWidget({ moduleId }) { currentNote = null; } } - }, sixteenthRate); + }, sixteenthRate * 2); // 2x rate for tighter detection clock.start(); partRef.current = clock; diff --git a/src/components/SequencerWidget.jsx b/src/components/SequencerWidget.jsx index 3a15bbf..d614391 100644 --- a/src/components/SequencerWidget.jsx +++ b/src/components/SequencerWidget.jsx @@ -70,34 +70,35 @@ export default function SequencerWidget({ moduleId }) { return; } - const sixteenthRate = (bpm * 4) / 60; - const stepDuration = 1 / sixteenthRate; - let step = 0; - let noteOffId = null; + const sixteenthRate = (bpm * 4) / 60; // Hz + const stepDuration = 1 / sixteenthRate; // seconds per step + const startTime = Tone.now(); + let lastStepIdx = -1; const clock = new Tone.Clock((time) => { - const stepIdx = step % numSteps; - step++; + // Derive step from audio clock time โ€” no counter drift + const elapsed = time - startTime; + const stepIdx = Math.floor(elapsed * sixteenthRate) % numSteps; + + // Only trigger on step change + if (stepIdx === lastStepIdx) return; + lastStepIdx = stepIdx; + const s = stepsRef.current[stepIdx]; if (!s) return; - // Update ref (not state!) โ€” visual follows via RAF currentStepRef.current = stepIdx; if (s.gate) { setSequencerSignals(moduleId, midiToFreq(s.midi), true); - // Schedule note-off using Tone.Draw or Tone.context - // Use the audio clock's time for precise scheduling - if (noteOffId !== null) { - try { Tone.getTransport().clear(noteOffId); } catch {} - } - noteOffId = Tone.getTransport().scheduleOnce(() => { + // Note-off via audio-thread timeout (not main-thread setTimeout) + Tone.getContext().setTimeout(() => { setSequencerSignals(moduleId, midiToFreq(s.midi), false); - }, time + stepDuration * 0.8); + }, stepDuration * 0.8); } else { setSequencerSignals(moduleId, midiToFreq(s.midi), false); } - }, sixteenthRate); + }, sixteenthRate * 2); // Run clock at 2x rate for tighter step detection clock.start(); clockRef.current = clock; From 1f941d7e39341e9b5af30de1dc9f4913ce5ac141 Mon Sep 17 00:00:00 2001 From: Jose Luis Date: Sat, 21 Mar 2026 17:45:10 +0100 Subject: [PATCH 13/22] feat: global master clock for drift-free multi-sequencer timing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace independent Tone.Clock per sequencer/pianoroll with a single shared master clock running at 960 Hz in audioEngine. Architecture: - Master clock starts/stops with audio engine (startAudio/stopAudio) - Widgets subscribe via subscribeTick(id, callback) receiving (audioTime, elapsed) on every tick - Each widget derives its own step/position from elapsed time and its own BPM, so different BPMs stay perfectly in sync - BPM/steps/bars changes are read from refs (no clock restart needed) Benefits: - All timing derived from one clock source = zero relative drift - No clock recreation on param changes = no glitches - 960 Hz tick rate โ‰ˆ 1ms precision (plenty for musical timing) - Sequencer at 80 BPM and 160 BPM maintain perfect 1:2 ratio forever Co-Authored-By: Claude Opus 4.6 (1M context) --- src/components/PianoRollWidget.jsx | 58 ++++++++++++++---------------- src/components/SequencerWidget.jsx | 45 ++++++++++------------- src/engine/audioEngine.js | 51 ++++++++++++++++++++++++-- 3 files changed, 92 insertions(+), 62 deletions(-) diff --git a/src/components/PianoRollWidget.jsx b/src/components/PianoRollWidget.jsx index 73474a5..46c9916 100644 --- a/src/components/PianoRollWidget.jsx +++ b/src/components/PianoRollWidget.jsx @@ -1,7 +1,7 @@ import React, { useState, useEffect, useRef, useCallback } from 'react'; import * as Tone from 'tone'; import { state, updateModuleParam, emit } from '../engine/state.js'; -import { setSequencerSignals } from '../engine/audioEngine.js'; +import { setSequencerSignals, subscribeTick, unsubscribeTick } from '../engine/audioEngine.js'; import { parseMidi } from '../utils/midiParser.js'; const NOTE_NAMES = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B']; @@ -90,7 +90,6 @@ const ROW_H = ROLL_H / NOTE_RANGE; export default function PianoRollWidget({ moduleId }) { const mod = state.modules.find(m => m.id === moduleId); const canvasRef = useRef(null); - const partRef = useRef(null); const [tool, setTool] = useState('draw'); // 'draw' | 'erase' const drawingRef = useRef(null); const rafRef = useRef(null); @@ -234,38 +233,40 @@ export default function PianoRollWidget({ moduleId }) { return () => { if (rafRef.current) cancelAnimationFrame(rafRef.current); }; }, [draw]); - // Playback โ€” uses independent Tone.Clock so multiple pianorolls/sequencers - // don't interfere with each other via the global Transport + // Subscribe to global master clock for playback + const bpmRef = useRef(bpm); + const loopRef = useRef(loop); + const totalBeatsRef = useRef(totalBeats); + bpmRef.current = bpm; + loopRef.current = loop; + totalBeatsRef.current = totalBeats; + useEffect(() => { if (!state.isRunning) { - if (partRef.current) { - try { partRef.current.stop(); } catch {} - try { partRef.current.dispose(); } catch {} - partRef.current = null; - } + unsubscribeTick(`pr-${moduleId}`); playPosRef.current = -1; return; } - const sixteenthRate = (bpm * 4) / 60; - const startTime = Tone.now(); let currentNote = null; - let lastPos = -1; + let lastQuantPos = -1; + + subscribeTick(`pr-${moduleId}`, (time, elapsed) => { + const currentBpm = bpmRef.current; + const currentLoop = loopRef.current; + const currentTotalBeats = totalBeatsRef.current; + const sixteenthRate = (currentBpm * 4) / 60; - const clock = new Tone.Clock((time) => { - // Derive position from audio clock โ€” no counter drift - const elapsed = time - startTime; const rawPos = elapsed * sixteenthRate * 0.25; // in beats - const pos = loop ? rawPos % totalBeats : rawPos; - - // Quantize to sixteenth resolution for consistent note detection + const pos = currentLoop ? rawPos % currentTotalBeats : rawPos; const quantPos = Math.floor(pos * 4) / 4; - // Detect loop wrap - const looped = lastPos >= 0 && quantPos < lastPos; - lastPos = quantPos; + // Only process on quantized position change + if (quantPos === lastQuantPos) return; + const looped = lastQuantPos >= 0 && quantPos < lastQuantPos; + lastQuantPos = quantPos; - if (!loop && rawPos >= totalBeats) { + if (!currentLoop && rawPos >= currentTotalBeats) { if (currentNote) { setSequencerSignals(moduleId, 0, false); currentNote = null; @@ -295,19 +296,12 @@ export default function PianoRollWidget({ moduleId }) { currentNote = null; } } - }, sixteenthRate * 2); // 2x rate for tighter detection - - clock.start(); - partRef.current = clock; + }); return () => { - if (partRef.current) { - try { partRef.current.stop(); } catch {} - try { partRef.current.dispose(); } catch {} - partRef.current = null; - } + unsubscribeTick(`pr-${moduleId}`); }; - }, [state.isRunning, moduleId, bpm, bars, loop]); + }, [state.isRunning, moduleId]); // Mouse interaction for drawing/erasing notes const handleMouseDown = useCallback((e) => { diff --git a/src/components/SequencerWidget.jsx b/src/components/SequencerWidget.jsx index d614391..39a2c77 100644 --- a/src/components/SequencerWidget.jsx +++ b/src/components/SequencerWidget.jsx @@ -1,7 +1,7 @@ import React, { useState, useEffect, useRef, useCallback } from 'react'; import * as Tone from 'tone'; import { state, updateModuleParam, emit } from '../engine/state.js'; -import { setSequencerSignals, getAudioNode } from '../engine/audioEngine.js'; +import { setSequencerSignals, getAudioNode, subscribeTick, unsubscribeTick } from '../engine/audioEngine.js'; const NOTE_NAMES = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B']; @@ -19,7 +19,6 @@ export default function SequencerWidget({ moduleId }) { const mod = state.modules.find(m => m.id === moduleId); const currentStepRef = useRef(-1); const [visualStep, setVisualStep] = useState(-1); - const clockRef = useRef(null); const stepsRef = useRef(null); const rafRef = useRef(null); @@ -57,30 +56,30 @@ export default function SequencerWidget({ moduleId }) { }; }, [state.isRunning]); - // Audio clock โ€” ONLY does audio work, no React state updates + // Subscribe to global master clock โ€” derive step from elapsed time + const bpmRef = useRef(bpm); + const numStepsRef = useRef(numSteps); + bpmRef.current = bpm; + numStepsRef.current = numSteps; + useEffect(() => { if (!state.isRunning) { - if (clockRef.current) { - try { clockRef.current.stop(); } catch {} - try { clockRef.current.dispose(); } catch {} - clockRef.current = null; - } + unsubscribeTick(`seq-${moduleId}`); currentStepRef.current = -1; setVisualStep(-1); return; } - const sixteenthRate = (bpm * 4) / 60; // Hz - const stepDuration = 1 / sixteenthRate; // seconds per step - const startTime = Tone.now(); let lastStepIdx = -1; + let noteOffTimeout = null; - const clock = new Tone.Clock((time) => { - // Derive step from audio clock time โ€” no counter drift - const elapsed = time - startTime; - const stepIdx = Math.floor(elapsed * sixteenthRate) % numSteps; + subscribeTick(`seq-${moduleId}`, (time, elapsed) => { + const currentBpm = bpmRef.current; + const currentNumSteps = numStepsRef.current; + const sixteenthRate = (currentBpm * 4) / 60; + const stepDuration = 1 / sixteenthRate; + const stepIdx = Math.floor(elapsed * sixteenthRate) % currentNumSteps; - // Only trigger on step change if (stepIdx === lastStepIdx) return; lastStepIdx = stepIdx; @@ -91,26 +90,18 @@ export default function SequencerWidget({ moduleId }) { if (s.gate) { setSequencerSignals(moduleId, midiToFreq(s.midi), true); - // Note-off via audio-thread timeout (not main-thread setTimeout) Tone.getContext().setTimeout(() => { setSequencerSignals(moduleId, midiToFreq(s.midi), false); }, stepDuration * 0.8); } else { setSequencerSignals(moduleId, midiToFreq(s.midi), false); } - }, sixteenthRate * 2); // Run clock at 2x rate for tighter step detection - - clock.start(); - clockRef.current = clock; + }); return () => { - if (clockRef.current) { - try { clockRef.current.stop(); } catch {} - try { clockRef.current.dispose(); } catch {} - clockRef.current = null; - } + unsubscribeTick(`seq-${moduleId}`); }; - }, [state.isRunning, moduleId, numSteps, bpm]); + }, [state.isRunning, moduleId]); const toggleGate = (idx) => { steps[idx] = { ...steps[idx], gate: !steps[idx].gate }; diff --git a/src/engine/audioEngine.js b/src/engine/audioEngine.js index b4d9262..fcd950f 100644 --- a/src/engine/audioEngine.js +++ b/src/engine/audioEngine.js @@ -12,6 +12,48 @@ const audioNodes = {}; // Active keyboard state const keyboardState = { frequency: 440, gate: false }; +// ==================== Global Master Clock ==================== +// Single high-resolution clock (960 ticks/sec โ‰ˆ 1ms precision). +// All sequencers/piano rolls derive their timing from this. +const MASTER_TICK_RATE = 960; // Hz +let _masterClock = null; +let _masterTime = 0; // audio-context seconds at clock start +const _tickListeners = new Map(); // id โ†’ callback(audioTime, elapsed) + +export function getMasterTime() { + if (!_masterClock) return 0; + return Tone.now() - _masterTime; +} + +export function subscribeTick(id, callback) { + _tickListeners.set(id, callback); +} + +export function unsubscribeTick(id) { + _tickListeners.delete(id); +} + +function startMasterClock() { + if (_masterClock) return; + _masterTime = Tone.now(); + _masterClock = new Tone.Clock((time) => { + const elapsed = time - _masterTime; + for (const cb of _tickListeners.values()) { + cb(time, elapsed); + } + }, MASTER_TICK_RATE); + _masterClock.start(); +} + +function stopMasterClock() { + if (_masterClock) { + try { _masterClock.stop(); } catch {} + try { _masterClock.dispose(); } catch {} + _masterClock = null; + } + _tickListeners.clear(); +} + // ==================== Node creation ==================== function createNode(mod) { @@ -434,18 +476,21 @@ export function triggerKeyboard(moduleId, freq, gate) { export async function startAudio() { await Tone.start(); state.isRunning = true; + startMasterClock(); // Rebuild entire audio graph rebuildGraph(); } export function stopAudio() { - // Stop and reset Transport so pianoroll/sequencer Parts don't get stranded + stopMasterClock(); + + // Stop and reset Transport try { Tone.getTransport().stop(); - Tone.getTransport().cancel(); // Remove all scheduled events + Tone.getTransport().cancel(); Tone.getTransport().position = 0; - } catch (e) { /* ignore if Transport not started */ } + } catch (e) {} // Destroy all nodes for (const id of Object.keys(audioNodes)) { From 18661961a1849b1d0f64fc4677ca1ab6333742f8 Mon Sep 17 00:00:00 2001 From: Jose Luis Date: Sat, 21 Mar 2026 17:52:38 +0100 Subject: [PATCH 14/22] fix: reduce master clock to 240 Hz + eliminate note-off timeouts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Master clock 960โ†’240 Hz: reduces CPU/GC pressure by 4x while still providing 12x headroom for 300 BPM sixteenths - Remove Tone.getContext().setTimeout() for note-off scheduling โ€” these accumulated over time causing periodic hiccups - Note-off now happens at step boundary: previous gate turned off at the start of each new step (cleaner, zero accumulation) Co-Authored-By: Claude Opus 4.6 (1M context) --- src/components/SequencerWidget.jsx | 15 ++++++++------- src/engine/audioEngine.js | 2 +- 2 files changed, 9 insertions(+), 8 deletions(-) diff --git a/src/components/SequencerWidget.jsx b/src/components/SequencerWidget.jsx index 39a2c77..e725737 100644 --- a/src/components/SequencerWidget.jsx +++ b/src/components/SequencerWidget.jsx @@ -71,18 +71,23 @@ export default function SequencerWidget({ moduleId }) { } let lastStepIdx = -1; - let noteOffTimeout = null; + let lastGateOn = false; subscribeTick(`seq-${moduleId}`, (time, elapsed) => { const currentBpm = bpmRef.current; const currentNumSteps = numStepsRef.current; const sixteenthRate = (currentBpm * 4) / 60; - const stepDuration = 1 / sixteenthRate; const stepIdx = Math.floor(elapsed * sixteenthRate) % currentNumSteps; if (stepIdx === lastStepIdx) return; lastStepIdx = stepIdx; + // Turn off previous note at step boundary (no setTimeout needed) + if (lastGateOn) { + setSequencerSignals(moduleId, 0, false); + lastGateOn = false; + } + const s = stepsRef.current[stepIdx]; if (!s) return; @@ -90,11 +95,7 @@ export default function SequencerWidget({ moduleId }) { if (s.gate) { setSequencerSignals(moduleId, midiToFreq(s.midi), true); - Tone.getContext().setTimeout(() => { - setSequencerSignals(moduleId, midiToFreq(s.midi), false); - }, stepDuration * 0.8); - } else { - setSequencerSignals(moduleId, midiToFreq(s.midi), false); + lastGateOn = true; } }); diff --git a/src/engine/audioEngine.js b/src/engine/audioEngine.js index fcd950f..d962962 100644 --- a/src/engine/audioEngine.js +++ b/src/engine/audioEngine.js @@ -15,7 +15,7 @@ const keyboardState = { frequency: 440, gate: false }; // ==================== Global Master Clock ==================== // Single high-resolution clock (960 ticks/sec โ‰ˆ 1ms precision). // All sequencers/piano rolls derive their timing from this. -const MASTER_TICK_RATE = 960; // Hz +const MASTER_TICK_RATE = 240; // Hz โ€” enough for 300 BPM sixteenths (20 Hz) with 12x headroom let _masterClock = null; let _masterTime = 0; // audio-context seconds at clock start const _tickListeners = new Map(); // id โ†’ callback(audioTime, elapsed) From 8bdb953b52b3014ed3fe89b0d6f20ac4bc092304 Mon Sep 17 00:00:00 2001 From: Jose Luis Date: Sat, 21 Mar 2026 17:57:10 +0100 Subject: [PATCH 15/22] fix: capture master clock start time from first tick callback The _masterTime was captured from Tone.now() BEFORE the clock started, but the time parameter in Tone.Clock callbacks comes from a different scheduler timeline. This caused elapsed to drift systematically. Now _masterTime is set from the first callback's own time parameter, guaranteeing both are on the exact same clock source. Zero drift. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/engine/audioEngine.js | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/engine/audioEngine.js b/src/engine/audioEngine.js index d962962..8e90623 100644 --- a/src/engine/audioEngine.js +++ b/src/engine/audioEngine.js @@ -35,8 +35,14 @@ export function unsubscribeTick(id) { function startMasterClock() { if (_masterClock) return; - _masterTime = Tone.now(); + _masterTime = 0; // Will be set from first tick + let _started = false; _masterClock = new Tone.Clock((time) => { + // Capture start time from the FIRST callback โ€” guarantees same clock source + if (!_started) { + _masterTime = time; + _started = true; + } const elapsed = time - _masterTime; for (const cb of _tickListeners.values()) { cb(time, elapsed); From 7d3a19ec35c56fc170911fbd8cefd262511a9ea6 Mon Sep 17 00:00:00 2001 From: Jose Luis Date: Sat, 21 Mar 2026 18:01:56 +0100 Subject: [PATCH 16/22] fix: use integer tick counter to eliminate floating-point beat drift MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root cause: floor(elapsed * rateA) vs floor(elapsed * rateB) where rateB = 2*rateA doesn't maintain exact 2:1 ratio due to floating-point multiplication errors. This creates a beat/aliasing pattern where sequencers at 80 and 160 BPM periodically go in and out of phase. Fix: Master clock now uses an integer tick counter (_masterTicks++) instead of floating-point elapsed time. Sequencers derive steps via: stepIdx = floor(ticks / ticksPerStep) % numSteps where ticks is an integer โ€” no floating-point accumulation possible. Also bumped master clock to 480 Hz for cleaner division at common BPMs: 80 BPM: 480*60/320 = 90 ticks/step (exact) 120 BPM: 480*60/480 = 60 ticks/step (exact) 160 BPM: 480*60/640 = 45 ticks/step (exact) Co-Authored-By: Claude Opus 4.6 (1M context) --- src/components/PianoRollWidget.jsx | 11 ++++++----- src/components/SequencerWidget.jsx | 10 ++++++---- src/engine/audioEngine.js | 28 +++++++++++----------------- 3 files changed, 23 insertions(+), 26 deletions(-) diff --git a/src/components/PianoRollWidget.jsx b/src/components/PianoRollWidget.jsx index 46c9916..31ca2fe 100644 --- a/src/components/PianoRollWidget.jsx +++ b/src/components/PianoRollWidget.jsx @@ -1,7 +1,7 @@ import React, { useState, useEffect, useRef, useCallback } from 'react'; import * as Tone from 'tone'; import { state, updateModuleParam, emit } from '../engine/state.js'; -import { setSequencerSignals, subscribeTick, unsubscribeTick } from '../engine/audioEngine.js'; +import { setSequencerSignals, subscribeTick, unsubscribeTick, MASTER_TICK_RATE } from '../engine/audioEngine.js'; import { parseMidi } from '../utils/midiParser.js'; const NOTE_NAMES = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B']; @@ -251,17 +251,18 @@ export default function PianoRollWidget({ moduleId }) { let currentNote = null; let lastQuantPos = -1; - subscribeTick(`pr-${moduleId}`, (time, elapsed) => { + subscribeTick(`pr-${moduleId}`, (time, ticks) => { const currentBpm = bpmRef.current; const currentLoop = loopRef.current; const currentTotalBeats = totalBeatsRef.current; - const sixteenthRate = (currentBpm * 4) / 60; - const rawPos = elapsed * sixteenthRate * 0.25; // in beats + // Convert ticks to beats: each beat = MASTER_TICK_RATE * 60 / bpm ticks + // Position in sixteenths: ticks / (ticksPerSixteenth) + const ticksPerBeat = MASTER_TICK_RATE * 60 / currentBpm; + const rawPos = ticks / ticksPerBeat; // in beats const pos = currentLoop ? rawPos % currentTotalBeats : rawPos; const quantPos = Math.floor(pos * 4) / 4; - // Only process on quantized position change if (quantPos === lastQuantPos) return; const looped = lastQuantPos >= 0 && quantPos < lastQuantPos; lastQuantPos = quantPos; diff --git a/src/components/SequencerWidget.jsx b/src/components/SequencerWidget.jsx index e725737..3bf0c10 100644 --- a/src/components/SequencerWidget.jsx +++ b/src/components/SequencerWidget.jsx @@ -1,7 +1,7 @@ import React, { useState, useEffect, useRef, useCallback } from 'react'; import * as Tone from 'tone'; import { state, updateModuleParam, emit } from '../engine/state.js'; -import { setSequencerSignals, getAudioNode, subscribeTick, unsubscribeTick } from '../engine/audioEngine.js'; +import { setSequencerSignals, getAudioNode, subscribeTick, unsubscribeTick, MASTER_TICK_RATE } from '../engine/audioEngine.js'; const NOTE_NAMES = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B']; @@ -73,11 +73,13 @@ export default function SequencerWidget({ moduleId }) { let lastStepIdx = -1; let lastGateOn = false; - subscribeTick(`seq-${moduleId}`, (time, elapsed) => { + subscribeTick(`seq-${moduleId}`, (time, ticks) => { const currentBpm = bpmRef.current; const currentNumSteps = numStepsRef.current; - const sixteenthRate = (currentBpm * 4) / 60; - const stepIdx = Math.floor(elapsed * sixteenthRate) % currentNumSteps; + // ticksPerStep = MASTER_TICK_RATE / sixteenthsPerSecond + // sixteenthsPerSecond = bpm * 4 / 60 + const ticksPerStep = MASTER_TICK_RATE * 60 / (currentBpm * 4); + const stepIdx = Math.floor(ticks / ticksPerStep) % currentNumSteps; if (stepIdx === lastStepIdx) return; lastStepIdx = stepIdx; diff --git a/src/engine/audioEngine.js b/src/engine/audioEngine.js index 8e90623..0239e25 100644 --- a/src/engine/audioEngine.js +++ b/src/engine/audioEngine.js @@ -13,16 +13,16 @@ const audioNodes = {}; const keyboardState = { frequency: 440, gate: false }; // ==================== Global Master Clock ==================== -// Single high-resolution clock (960 ticks/sec โ‰ˆ 1ms precision). -// All sequencers/piano rolls derive their timing from this. -const MASTER_TICK_RATE = 240; // Hz โ€” enough for 300 BPM sixteenths (20 Hz) with 12x headroom +// Single clock with integer tick counter. All sequencers/piano rolls +// derive their step positions from this shared tick count. +// Using integers avoids floating-point drift entirely. +export const MASTER_TICK_RATE = 480; // Hz โ€” must be high enough for fastest BPM let _masterClock = null; -let _masterTime = 0; // audio-context seconds at clock start -const _tickListeners = new Map(); // id โ†’ callback(audioTime, elapsed) +let _masterTicks = 0; +const _tickListeners = new Map(); // id โ†’ callback(audioTime, ticks) -export function getMasterTime() { - if (!_masterClock) return 0; - return Tone.now() - _masterTime; +export function getMasterTicks() { + return _masterTicks; } export function subscribeTick(id, callback) { @@ -35,17 +35,11 @@ export function unsubscribeTick(id) { function startMasterClock() { if (_masterClock) return; - _masterTime = 0; // Will be set from first tick - let _started = false; + _masterTicks = 0; _masterClock = new Tone.Clock((time) => { - // Capture start time from the FIRST callback โ€” guarantees same clock source - if (!_started) { - _masterTime = time; - _started = true; - } - const elapsed = time - _masterTime; + _masterTicks++; for (const cb of _tickListeners.values()) { - cb(time, elapsed); + cb(time, _masterTicks); } }, MASTER_TICK_RATE); _masterClock.start(); From 7596aea4912e21095ab3b4bb7b5f70533c8990d8 Mon Sep 17 00:00:00 2001 From: Jose Luis Date: Sat, 21 Mar 2026 18:08:10 +0100 Subject: [PATCH 17/22] fix: derive master clock ticks from AudioContext.currentTime MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The _masterTicks++ counter fell behind when Tone.Clock callbacks were delayed by GC pauses, UI interactions, or tab throttling. The counter never recovered, causing cumulative drift between sequencers. Now ticks are derived from the callback's time parameter (which comes from AudioContext.currentTime โ€” hardware clock, always precise): ticks = Math.round((time - startTime) * MASTER_TICK_RATE) If a callback is delayed by 50ms, the time is still correct and ticks jump ahead to the right value. No accumulation, no drift, self-healing. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/engine/audioEngine.js | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/src/engine/audioEngine.js b/src/engine/audioEngine.js index 0239e25..b38d97a 100644 --- a/src/engine/audioEngine.js +++ b/src/engine/audioEngine.js @@ -18,13 +18,8 @@ const keyboardState = { frequency: 440, gate: false }; // Using integers avoids floating-point drift entirely. export const MASTER_TICK_RATE = 480; // Hz โ€” must be high enough for fastest BPM let _masterClock = null; -let _masterTicks = 0; const _tickListeners = new Map(); // id โ†’ callback(audioTime, ticks) -export function getMasterTicks() { - return _masterTicks; -} - export function subscribeTick(id, callback) { _tickListeners.set(id, callback); } @@ -35,11 +30,16 @@ export function unsubscribeTick(id) { function startMasterClock() { if (_masterClock) return; - _masterTicks = 0; + let _startTime = 0; + let _started = false; _masterClock = new Tone.Clock((time) => { - _masterTicks++; + if (!_started) { _startTime = time; _started = true; } + // Derive ticks from precise AudioContext.currentTime, not a counter. + // Counters fall behind when callbacks are delayed (GC, UI, tab throttle). + // The time parameter is always accurate regardless of callback jitter. + const ticks = Math.round((time - _startTime) * MASTER_TICK_RATE); for (const cb of _tickListeners.values()) { - cb(time, _masterTicks); + cb(time, ticks); } }, MASTER_TICK_RATE); _masterClock.start(); From 7e6c960b0b514812a9097a94aaa0ecc3c65c76f2 Mon Sep 17 00:00:00 2001 From: Jose Luis Date: Sat, 21 Mar 2026 18:19:12 +0100 Subject: [PATCH 18/22] fix: reduce main thread pressure to prevent audio buffer underruns MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The periodic audio glitches were caused by main thread starvation: ~840 events/sec during playback starved the audio buffer. Changes: - Master clock 480โ†’120 Hz (still 6x headroom for 300 BPM sixteenths) - Connection cache: replace O(n) reduce hash with dirty flag (zero work on cache hit, flag set only when connections actually change) - Tone.js lookAhead: 100msโ†’50ms for tighter scheduling - ModuleNode LFO visualization RAF: 60fpsโ†’15fps (every 4th frame) - ScopeDisplay RAF: 60fpsโ†’30fps (every 2nd frame) Net effect: ~840 events/sec โ†’ ~200 events/sec during playback. Audio processing gets 4x more main thread headroom. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/components/ModuleNode.jsx | 7 ++++++- src/components/ScopeDisplay.jsx | 7 ++++++- src/engine/audioEngine.js | 16 +++++++++------- src/engine/state.js | 3 +++ src/main.jsx | 3 ++- 5 files changed, 26 insertions(+), 10 deletions(-) diff --git a/src/components/ModuleNode.jsx b/src/components/ModuleNode.jsx index 400dcb7..6285969 100644 --- a/src/components/ModuleNode.jsx +++ b/src/components/ModuleNode.jsx @@ -71,7 +71,13 @@ export default function ModuleNode({ mod, zoom, onStartConnect, onPortPosition } return; } + let frameCount = 0; const tick = () => { + frameCount++; + rafRef.current = requestAnimationFrame(tick); + // Throttle to ~15fps (every 4th frame) to reduce main thread pressure + if (frameCount % 4 !== 0) return; + const t = performance.now() / 1000 - startTimeRef.current; const newValues = {}; @@ -104,7 +110,6 @@ export default function ModuleNode({ mod, zoom, onStartConnect, onPortPosition } } setLiveValues(newValues); - rafRef.current = requestAnimationFrame(tick); }; rafRef.current = requestAnimationFrame(tick); diff --git a/src/components/ScopeDisplay.jsx b/src/components/ScopeDisplay.jsx index 7ac45cf..6ef7612 100644 --- a/src/components/ScopeDisplay.jsx +++ b/src/components/ScopeDisplay.jsx @@ -22,7 +22,13 @@ export default function ScopeDisplay({ moduleId }) { const w = canvas.width = 160; const h = canvas.height = 60; + let frameCount = 0; const draw = () => { + frameCount++; + rafRef.current = requestAnimationFrame(draw); + // Throttle to ~30fps to reduce main thread pressure during playback + if (frameCount % 2 !== 0) return; + ctx.fillStyle = '#050510'; ctx.fillRect(0, 0, w, h); @@ -58,7 +64,6 @@ export default function ScopeDisplay({ moduleId }) { ctx.stroke(); } - rafRef.current = requestAnimationFrame(draw); }; draw(); diff --git a/src/engine/audioEngine.js b/src/engine/audioEngine.js index b38d97a..07b6f5e 100644 --- a/src/engine/audioEngine.js +++ b/src/engine/audioEngine.js @@ -16,7 +16,7 @@ const keyboardState = { frequency: 440, gate: false }; // Single clock with integer tick counter. All sequencers/piano rolls // derive their step positions from this shared tick count. // Using integers avoids floating-point drift entirely. -export const MASTER_TICK_RATE = 480; // Hz โ€” must be high enough for fastest BPM +export const MASTER_TICK_RATE = 120; // Hz โ€” 6x headroom for 300 BPM sixteenths (20 Hz). Lower = less main thread pressure. let _masterClock = null; const _tickListeners = new Map(); // id โ†’ callback(audioTime, ticks) @@ -406,21 +406,23 @@ export function updateParam(moduleId, paramName, value) { } // Cache connection lookups for hot-path audio scheduling -// Rebuilt lazily when connections change -let _connCacheVersion = -1; +// Rebuilt only when connections actually change (dirty flag, no computation on hit) +let _connCacheDirty = true; const _connByModulePort = new Map(); // "moduleId-portName" โ†’ [connections] +export function invalidateConnectionCache() { + _connCacheDirty = true; +} + function getConnectionsFrom(moduleId, portName) { - // Rebuild cache if connections changed - const version = state.connections.length + state.connections.reduce((s, c) => s + c.id, 0); - if (version !== _connCacheVersion) { + if (_connCacheDirty) { _connByModulePort.clear(); for (const conn of state.connections) { const key = `${conn.from.moduleId}-${conn.from.port}`; if (!_connByModulePort.has(key)) _connByModulePort.set(key, []); _connByModulePort.get(key).push(conn); } - _connCacheVersion = version; + _connCacheDirty = false; } return _connByModulePort.get(`${moduleId}-${portName}`) || []; } diff --git a/src/engine/state.js b/src/engine/state.js index dbef959..cce06ec 100644 --- a/src/engine/state.js +++ b/src/engine/state.js @@ -4,6 +4,7 @@ */ import { playConnect, playDisconnect, playModuleAdd, playModuleDelete } from './uiSounds.js'; import { getModuleDef } from './moduleRegistry.js'; +import { invalidateConnectionCache } from './audioEngine.js'; let _listeners = new Set(); let _nextModuleId = 1; @@ -93,6 +94,7 @@ export function addConnection(fromModuleId, fromPort, toModuleId, toPort) { const id = _nextConnectionId++; state.connections.push({ id, from: { moduleId: fromModuleId, port: fromPort }, to: { moduleId: toModuleId, port: toPort } }); + invalidateConnectionCache(); emit(); playConnect(); return id; @@ -100,6 +102,7 @@ export function addConnection(fromModuleId, fromPort, toModuleId, toPort) { export function removeConnection(id, _silent = false) { state.connections = state.connections.filter(c => c.id !== id); + invalidateConnectionCache(); emit(); if (!_silent) playDisconnect(); } diff --git a/src/main.jsx b/src/main.jsx index 2ca5599..5ad89a6 100644 --- a/src/main.jsx +++ b/src/main.jsx @@ -16,8 +16,9 @@ function Root() { createRoot(document.getElementById('root')).render(); -// Unlock audio context on first user interaction +// Configure and unlock audio context on first user interaction import * as Tone from 'tone'; +Tone.getContext().lookAhead = 0.05; // 50ms โ€” tighter than default 100ms const unlockAudio = () => { if (Tone.context.state !== 'running') { Tone.start().catch(() => {}); From 38dca9402f05ace581633d61d37cb2ad92c304a2 Mon Sep 17 00:00:00 2001 From: Jose Luis Date: Sat, 21 Mar 2026 18:44:28 +0100 Subject: [PATCH 19/22] =?UTF-8?q?fix:=20VCA=20closes=20properly=20with=20e?= =?UTF-8?q?nvelope=20+=20add=20CV=E2=86=92Gate=20module?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit VCA fix: - Add cvMod scaler (like oscillator/filter have) so envelope (0-1) is scaled by the gain param before modulating VCA - Zero base gain when CV is connected (in rebuildGraph) so envelope = 0 produces silence instead of falling back to base gain - updateParam keeps cvMod in sync with gain knob New module: CVโ†’Gate (โšก) in Utility category: - Converts continuous CV signal (e.g. LFO) to gate on/off - Threshold knob (0-1, default 0.5): signal above = gate on - Reads analyser on master clock tick for threshold comparison - Triggers/releases connected envelopes automatically - Use case: LFO โ†’ CVโ†’Gate โ†’ Envelope โ†’ VCA for rhythmic gating Co-Authored-By: Claude Opus 4.6 (1M context) --- src/engine/audioEngine.js | 64 +++++++++++++++++++++++++++++++++--- src/engine/moduleRegistry.js | 17 ++++++++++ 2 files changed, 77 insertions(+), 4 deletions(-) diff --git a/src/engine/audioEngine.js b/src/engine/audioEngine.js index 07b6f5e..0ef8391 100644 --- a/src/engine/audioEngine.js +++ b/src/engine/audioEngine.js @@ -126,13 +126,16 @@ function createNode(mod) { }; } case 'vca': { - // Use a Multiply node: in ร— cv const gain = new Tone.Gain(p.gain); + // CV modulation scaler: envelope (0-1) ร— gain param โ†’ added to gain.gain + const cvMod = new Tone.Gain(p.gain); + cvMod.connect(gain.gain); return { node: gain, - inputs: { in: gain, cv: gain.gain }, + _cvMod: cvMod, + inputs: { in: gain, cv: cvMod }, outputs: { out: gain }, - dispose: () => gain.dispose(), + dispose: () => { cvMod.disconnect(); cvMod.dispose(); gain.dispose(); }, }; } case 'delay': { @@ -187,6 +190,20 @@ function createNode(mod) { dispose: () => analyser.dispose(), }; } + case 'cv2gate': { + // Converts a continuous CV signal to gate on/off based on threshold. + // Uses an analyser to read the CV value and triggers connected envelopes. + const analyser = new Tone.Analyser('waveform', 32); + const gateSig = new Tone.Signal(0); + return { + node: analyser, + _gateSig: gateSig, + _gateState: false, + inputs: { in: analyser }, + outputs: { gate: gateSig }, + dispose: () => { analyser.dispose(); gateSig.dispose(); }, + }; + } case 'output': { // True stereo output: separate left/right channels โ†’ merge โ†’ master gain โ†’ destination const leftGain = new Tone.Gain(1); @@ -372,7 +389,11 @@ export function updateParam(moduleId, paramName, value) { else if (paramName === 'release') entry.node.release = value; break; case 'vca': - if (paramName === 'gain') entry.node.gain.value = value; + if (paramName === 'gain') { + const hasCV = state.connections.some(c => c.to.moduleId === moduleId && c.to.port === 'cv'); + if (!hasCV) entry.node.gain.value = value; + if (entry._cvMod) entry._cvMod.gain.value = value; + } break; case 'delay': if (paramName === 'delayTime') entry.node.delayTime.value = value; @@ -398,6 +419,7 @@ export function updateParam(moduleId, paramName, value) { break; case 'keyboard': case 'drumpad': + case 'cv2gate': case 'sequencer': case 'pianoroll': // All params stored in state, managed by widgets @@ -517,6 +539,15 @@ export function rebuildGraph() { connectWire(conn); } + // Zero base gain on VCAs with active CV connection. + // When envelope controls VCA, base gain must be 0 so silence is possible. + for (const mod of state.modules) { + if (mod.type !== 'vca') continue; + const hasCV = state.connections.some(c => c.to.moduleId === mod.id && c.to.port === 'cv'); + const entry = audioNodes[mod.id]; + if (entry && hasCV) entry.node.gain.value = 0; + } + // Auto-trigger envelopes that have no gate connection (free-running mode). // This allows noise/ambient patches to work without a keyboard/sequencer. for (const mod of state.modules) { @@ -531,6 +562,31 @@ export function rebuildGraph() { } } } + + // Register CVโ†’Gate modules on master clock for threshold detection + for (const mod of state.modules) { + if (mod.type !== 'cv2gate') continue; + const entry = audioNodes[mod.id]; + if (!entry) continue; + subscribeTick(`cv2gate-${mod.id}`, () => { + const data = entry.node.getValue(); + const sample = typeof data === 'number' ? data : (data?.[0] ?? 0); + const threshold = mod.params?.threshold ?? 0.5; + const gateOn = sample > threshold; + if (gateOn !== entry._gateState) { + entry._gateState = gateOn; + entry._gateSig.value = gateOn ? 1 : 0; + // Trigger/release connected envelopes + for (const conn of getConnectionsFrom(mod.id, 'gate')) { + const envEntry = audioNodes[conn.to.moduleId]; + if (envEntry && envEntry.node instanceof Tone.Envelope) { + if (gateOn) envEntry.node.triggerAttack(); + else envEntry.node.triggerRelease(); + } + } + } + }); + } } export function getAnalyserData(moduleId) { diff --git a/src/engine/moduleRegistry.js b/src/engine/moduleRegistry.js index 5b7250e..2a81a2f 100644 --- a/src/engine/moduleRegistry.js +++ b/src/engine/moduleRegistry.js @@ -226,6 +226,23 @@ defineModule('scope', { params: {}, }); +// ==================== CV TO GATE ==================== + +defineModule('cv2gate', { + name: 'CVโ†’Gate', + icon: 'โšก', + category: 'Utility', + inputs: [ + { name: 'in', type: PORT_TYPE.CONTROL, label: 'CV In' }, + ], + outputs: [ + { name: 'gate', type: PORT_TYPE.TRIGGER, label: 'Gate' }, + ], + params: { + threshold: { type: 'knob', min: 0, max: 1, default: 0.5, unit: '', label: 'Thresh' }, + }, +}); + // ==================== OUTPUT ==================== defineModule('output', { From 2a2b3b33419020aa1ee52dc9d5d1111d69ea4422 Mon Sep 17 00:00:00 2001 From: Jose Luis Date: Sat, 21 Mar 2026 18:50:28 +0100 Subject: [PATCH 20/22] fix: VCA zeroes on live CV connect + visual feedback for envelope control VCA fix: - connectWire now zeros VCA base gain immediately when CV is connected (previously only rebuildGraph did this, missing live-connect case) - disconnectWire restores base gain from params when CV is removed Visual modulation feedback: - ModuleNode RAF loop now handles envelope sources (not just LFO) - Reads actual Tone.js gain node value for real-time display - VCA gain knob shows live envelope value during playback - LFO visualization unchanged (simulated waveform as before) Co-Authored-By: Claude Opus 4.6 (1M context) --- src/components/ModuleNode.jsx | 47 ++++++++++++++++++++--------------- src/engine/audioEngine.js | 11 ++++++++ 2 files changed, 38 insertions(+), 20 deletions(-) diff --git a/src/components/ModuleNode.jsx b/src/components/ModuleNode.jsx index 6285969..3f171e0 100644 --- a/src/components/ModuleNode.jsx +++ b/src/components/ModuleNode.jsx @@ -1,7 +1,7 @@ import React, { useCallback, useState, useEffect, useRef } from 'react'; import { getModuleDef } from '../engine/moduleRegistry.js'; import { state, removeModule, updateModuleParam, isPortConnected, emit } from '../engine/state.js'; -import { updateParam } from '../engine/audioEngine.js'; +import { updateParam, getAudioNode } from '../engine/audioEngine.js'; import Knob from './Knob.jsx'; import ScopeDisplay from './ScopeDisplay.jsx'; import KeyboardWidget from './KeyboardWidget.jsx'; @@ -60,7 +60,7 @@ export default function ModuleNode({ mod, zoom, onStartConnect, onPortPosition } } } - // ==================== Live LFO modulation visualization ==================== + // ==================== Live modulation visualization (LFO + Envelope + any CV) ==================== const [liveValues, setLiveValues] = useState({}); const rafRef = useRef(null); const startTimeRef = useRef(performance.now() / 1000); @@ -75,7 +75,6 @@ export default function ModuleNode({ mod, zoom, onStartConnect, onPortPosition } const tick = () => { frameCount++; rafRef.current = requestAnimationFrame(tick); - // Throttle to ~15fps (every 4th frame) to reduce main thread pressure if (frameCount % 4 !== 0) return; const t = performance.now() / 1000 - startTimeRef.current; @@ -87,26 +86,34 @@ export default function ModuleNode({ mod, zoom, onStartConnect, onPortPosition } if (!paramName) continue; const srcMod = state.modules.find(m => m.id === conn.from.moduleId); - if (!srcMod || srcMod.type !== 'lfo') continue; + if (!srcMod) continue; - // Read LFO params from state - const lfoDef = getModuleDef('lfo'); - const lfoP = { ...Object.fromEntries(Object.entries(lfoDef.params).map(([k, v]) => [k, v.default])), ...srcMod.params }; - const freq = lfoP.frequency; - const amp = lfoP.amplitude; - const waveform = lfoP.waveform; - const phase = (t * freq) % 1; - const lfoVal = simulateLFO(waveform, phase) * amp; + if (srcMod.type === 'lfo') { + // LFO: simulate waveform for smooth visual + const lfoDef = getModuleDef('lfo'); + const lfoP = { ...Object.fromEntries(Object.entries(lfoDef.params).map(([k, v]) => [k, v.default])), ...srcMod.params }; + const freq = lfoP.frequency; + const amp = lfoP.amplitude; + const waveform = lfoP.waveform; + const phase = (t * freq) % 1; + const lfoVal = simulateLFO(waveform, phase) * amp; - // Compute modulated value (same scaling as audioEngine) - const baseValue = params[paramName]; - let scale; - if (mod.type === 'oscillator' && paramName === 'frequency') scale = baseValue * 0.5; - else if (mod.type === 'filter' && paramName === 'frequency') scale = baseValue; - else if (mod.type === 'vca' && paramName === 'gain') scale = 1; - else scale = baseValue || 1; + const baseValue = params[paramName]; + let scale; + if (mod.type === 'oscillator' && paramName === 'frequency') scale = baseValue * 0.5; + else if (mod.type === 'filter' && paramName === 'frequency') scale = baseValue; + else if (mod.type === 'vca' && paramName === 'gain') scale = 1; + else scale = baseValue || 1; - newValues[paramName] = baseValue + lfoVal * scale; + newValues[paramName] = baseValue + lfoVal * scale; + } else if (srcMod.type === 'envelope') { + // Envelope: read the actual audio node gain value for real-time display + const audioEntry = getAudioNode(mod.id); + if (audioEntry?.node?.gain) { + const currentGain = audioEntry.node.gain.value; + newValues[paramName] = currentGain; + } + } } setLiveValues(newValues); diff --git a/src/engine/audioEngine.js b/src/engine/audioEngine.js index 0ef8391..ad5b387 100644 --- a/src/engine/audioEngine.js +++ b/src/engine/audioEngine.js @@ -327,6 +327,11 @@ export function connectWire(conn) { } catch (e) { console.warn('connect error', e); } + + // When CV is connected to VCA, zero the base gain so only envelope controls it + if (toMod?.type === 'vca' && conn.to.port === 'cv') { + toEntry.node.gain.value = 0; + } } export function disconnectWire(conn) { @@ -345,6 +350,12 @@ export function disconnectWire(conn) { } catch (e) { // Tone.js may throw if not connected } + + // When CV is disconnected from VCA, restore base gain from params + const toMod = state.modules.find(m => m.id === conn.to.moduleId); + if (toMod?.type === 'vca' && conn.to.port === 'cv') { + toEntry.node.gain.value = toMod.params?.gain ?? 0.8; + } } export function updateParam(moduleId, paramName, value) { From 49c016d0a605703a882f4bcbbfddfb4cc88ad302 Mon Sep 17 00:00:00 2001 From: Jose Luis Date: Sat, 21 Mar 2026 18:51:42 +0100 Subject: [PATCH 21/22] fix: prevent envelope release=0 causing sustain loop bug MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tone.Envelope with release=0 behaves unpredictably โ€” the gate-off ramp doesn't complete properly and the value snaps back to sustain. Set minimum release to 0.001s (same as attack/decay already have). Co-Authored-By: Claude Opus 4.6 (1M context) --- src/engine/moduleRegistry.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/engine/moduleRegistry.js b/src/engine/moduleRegistry.js index 2a81a2f..105ec49 100644 --- a/src/engine/moduleRegistry.js +++ b/src/engine/moduleRegistry.js @@ -117,7 +117,7 @@ defineModule('envelope', { attack: { type: 'knob', min: 0.001, max: 4, default: 0.01, unit: 's', label: 'Attack' }, decay: { type: 'knob', min: 0.001, max: 4, default: 0.2, unit: 's', label: 'Decay' }, sustain: { type: 'knob', min: 0, max: 1, default: 0.5, unit: '', label: 'Sustain' }, - release: { type: 'knob', min: 0, max: 8, default: 0.5, unit: 's', label: 'Release' }, + release: { type: 'knob', min: 0.001, max: 8, default: 0.5, unit: 's', label: 'Release' }, }, }); From 02db83b896e1f387aa0052ff61203e1eca59c711 Mon Sep 17 00:00:00 2001 From: Jose Luis Date: Sat, 21 Mar 2026 18:55:29 +0100 Subject: [PATCH 22/22] fix: VCA CV scaler always 1 so envelope works regardless of gain param MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bug: if VCA gain was 0 when CV was connected, cvMod (initialized with p.gain=0) would multiply envelope by 0 = silence forever. Fix: cvMod always has gain=1 (full pass-through). The envelope (0-1) controls the VCA amplitude directly. When CV is connected, base gain is zeroed so only the envelope signal is heard. When disconnected, base gain is restored from the param value. Before: cvMod.gain = p.gain โ†’ envelope ร— 0 = 0 (broken) After: cvMod.gain = 1 โ†’ envelope ร— 1 = envelope (correct) Co-Authored-By: Claude Opus 4.6 (1M context) --- src/engine/audioEngine.js | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/engine/audioEngine.js b/src/engine/audioEngine.js index ad5b387..0894599 100644 --- a/src/engine/audioEngine.js +++ b/src/engine/audioEngine.js @@ -127,8 +127,9 @@ function createNode(mod) { } case 'vca': { const gain = new Tone.Gain(p.gain); - // CV modulation scaler: envelope (0-1) ร— gain param โ†’ added to gain.gain - const cvMod = new Tone.Gain(p.gain); + // CV scaler: always gain=1 so envelope (0-1) passes through fully. + // When CV is connected, base gain is zeroed โ€” envelope controls amplitude entirely. + const cvMod = new Tone.Gain(1); cvMod.connect(gain.gain); return { node: gain, @@ -401,9 +402,10 @@ export function updateParam(moduleId, paramName, value) { break; case 'vca': if (paramName === 'gain') { + // Only update base gain if no CV is connected (CV zeroes it) const hasCV = state.connections.some(c => c.to.moduleId === moduleId && c.to.port === 'cv'); if (!hasCV) entry.node.gain.value = value; - if (entry._cvMod) entry._cvMod.gain.value = value; + // cvMod stays at 1 always โ€” envelope controls full range } break; case 'delay':