diff --git a/index.html b/index.html index 903f633..56f0f0c 100644 --- a/index.html +++ b/index.html @@ -2,9 +2,14 @@ - + Reaktor โ€” MontLab Modular Synth + + + + +
diff --git a/public/icon-192.png b/public/icon-192.png new file mode 100644 index 0000000..723ed12 Binary files /dev/null and b/public/icon-192.png differ diff --git a/public/icon-512.png b/public/icon-512.png new file mode 100644 index 0000000..2de1180 Binary files /dev/null and b/public/icon-512.png differ diff --git a/public/manifest.json b/public/manifest.json new file mode 100644 index 0000000..4468fba --- /dev/null +++ b/public/manifest.json @@ -0,0 +1,15 @@ +{ + "name": "Reaktor โ€” MontLab Modular Synth", + "short_name": "Reaktor", + "description": "Modular synthesizer & SynthQuest puzzle game", + "start_url": "/", + "display": "standalone", + "orientation": "any", + "background_color": "#08080f", + "theme_color": "#00e5ff", + "icons": [ + { "src": "/favicon.svg", "sizes": "any", "type": "image/svg+xml", "purpose": "any" }, + { "src": "/icon-192.png", "sizes": "192x192", "type": "image/png" }, + { "src": "/icon-512.png", "sizes": "512x512", "type": "image/png" } + ] +} diff --git a/public/sw.js b/public/sw.js new file mode 100644 index 0000000..22d088e --- /dev/null +++ b/public/sw.js @@ -0,0 +1,33 @@ +const CACHE_NAME = 'reaktor-v1'; + +self.addEventListener('install', (e) => { + 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/App.jsx b/src/App.jsx index cbe4464..2d500a9 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -8,7 +8,11 @@ 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 { usePinchZoom } from './hooks/usePinchZoom.js'; +import { getModulesByCategory } from './engine/moduleRegistry.js'; export default function App({ onSwitchToGame }) { const [, forceUpdate] = useState(0); @@ -18,6 +22,13 @@ 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); + + // 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(() => { @@ -86,10 +97,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) { @@ -249,44 +267,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..d141cfe --- /dev/null +++ b/src/components/BottomSheet.jsx @@ -0,0 +1,52 @@ +import { useState, useRef, useCallback } from 'react'; + +export default function BottomSheet({ tabs, activeTab, onTabChange, children, className = '' }) { + const [expanded, setExpanded] = useState(false); + const startY = useRef(0); + + const handleTouchStart = useCallback((e) => { + startY.current = e.touches[0].clientY; + }, []); + + const handleTouchEnd = useCallback((e) => { + const deltaY = e.changedTouches[0].clientY - startY.current; + if (deltaY < -30) setExpanded(true); + if (deltaY > 30) setExpanded(false); + }, []); + + return ( +
+
setExpanded(v => !v)}> +
+ {!expanded && !tabs && ( + Modulos โ–ฒ + )} +
+ + {tabs && tabs.length > 0 && ( +
!expanded && setExpanded(true)}> + {tabs.map(tab => ( + + ))} +
+ )} + + {expanded && ( +
+ {children} +
+ )} +
+ ); +} diff --git a/src/components/DrumPadWidget.jsx b/src/components/DrumPadWidget.jsx new file mode 100644 index 0000000..70be250 --- /dev/null +++ b/src/components/DrumPadWidget.jsx @@ -0,0 +1,109 @@ +import React, { useCallback, useState } from 'react'; +import { createPortal } from 'react-dom'; +import { triggerKeyboard } from '../engine/audioEngine.js'; + +// 4x4 pad layout โ€” each pad maps to a MIDI note +const PAD_NOTES = [ + { note: 36, label: 'C2', color: '#ff4466' }, + { note: 38, label: 'D2', color: '#ff6644' }, + { note: 40, label: 'E2', color: '#ffcc00' }, + { note: 42, label: 'F#2', color: '#44ff88' }, + { note: 43, label: 'G2', color: '#00e5ff' }, + { note: 45, label: 'A2', color: '#aa55ff' }, + { note: 47, label: 'B2', color: '#ff4466' }, + { note: 48, label: 'C3', color: '#ff6644' }, + { note: 50, label: 'D3', color: '#ffcc00' }, + { note: 52, label: 'E3', color: '#44ff88' }, + { note: 53, label: 'F3', color: '#00e5ff' }, + { note: 55, label: 'G3', color: '#aa55ff' }, + { note: 57, label: 'A3', color: '#ff4466' }, + { note: 59, label: 'B3', color: '#ff6644' }, + { note: 60, label: 'C4', color: '#ffcc00' }, + { note: 62, label: 'D4', color: '#44ff88' }, +]; + +function midiToFreq(midi) { + return 440 * Math.pow(2, (midi - 69) / 12); +} + +function FullscreenDrumPad({ moduleId, onClose }) { + const [activePad, setActivePad] = useState(-1); + + const hitPad = useCallback((pad, idx) => { + triggerKeyboard(moduleId, midiToFreq(pad.note), true); + setActivePad(idx); + setTimeout(() => { + triggerKeyboard(moduleId, midiToFreq(pad.note), false); + setActivePad(-1); + }, 150); + }, [moduleId]); + + return ( +
+
+ ๐Ÿฅ Drum Pads + +
+
+ {PAD_NOTES.map((pad, i) => ( +
hitPad(pad, i)} + > + {pad.label} + {i + 1} +
+ ))} +
+
+ ); +} + +export default function DrumPadWidget({ moduleId, fullscreen, onCloseFullscreen }) { + const [activePad, setActivePad] = useState(-1); + + const hitPad = useCallback((pad, idx) => { + triggerKeyboard(moduleId, midiToFreq(pad.note), true); + setActivePad(idx); + setTimeout(() => { + triggerKeyboard(moduleId, midiToFreq(pad.note), false); + setActivePad(-1); + }, 150); + }, [moduleId]); + + return ( + <> +
+
+ {PAD_NOTES.map((pad, i) => ( +
{ e.stopPropagation(); hitPad(pad, i); }} + > + {pad.label} +
+ ))} +
+
+ Tap pads to trigger +
+
+ + {fullscreen && createPortal( + , + document.body + )} + + ); +} diff --git a/src/components/KeyboardWidget.jsx b/src/components/KeyboardWidget.jsx index 6afc1ba..63cc5f2 100644 --- a/src/components/KeyboardWidget.jsx +++ b/src/components/KeyboardWidget.jsx @@ -1,10 +1,10 @@ -import React, { useCallback, useEffect, useRef } from 'react'; +import React, { useCallback, useEffect, useRef, useState } from 'react'; +import { createPortal } from 'react-dom'; import { triggerKeyboard } from '../engine/audioEngine.js'; import { state } from '../engine/state.js'; const NOTE_NAMES = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B']; -// Computer keyboard to semitone offset mapping (2 octaves) const KEY_MAP = { 'z': 0, 's': 1, 'x': 2, 'd': 3, 'c': 4, 'v': 5, 'g': 6, 'b': 7, 'h': 8, 'n': 9, 'j': 10, 'm': 11, ',': 12, @@ -16,7 +16,87 @@ function midiToFreq(midi) { return 440 * Math.pow(2, (midi - 69) / 12); } -export default function KeyboardWidget({ moduleId }) { +// Fullscreen piano โ€” 1 octave, big comfortable keys like a real piano app +function FullscreenPiano({ moduleId, initialOctave, onClose }) { + const [oct, setOct] = useState(initialOctave); + const [activeNotes, setActiveNotes] = useState(new Set()); + + const play = useCallback((semitone) => { + const midi = (oct + 1) * 12 + semitone; + triggerKeyboard(moduleId, midiToFreq(midi), true); + setActiveNotes(prev => new Set(prev).add(semitone)); + }, [moduleId, oct]); + + const stop = useCallback((semitone) => { + setActiveNotes(prev => { + const next = new Set(prev); + next.delete(semitone); + if (next.size === 0) triggerKeyboard(moduleId, 440, false); + return next; + }); + }, [moduleId]); + + // 1 octave: 7 white keys, 5 black keys + const whiteKeys = [ + { note: 0, name: 'C' }, + { note: 2, name: 'D' }, + { note: 4, name: 'E' }, + { note: 5, name: 'F' }, + { note: 7, name: 'G' }, + { note: 9, name: 'A' }, + { note: 11, name: 'B' }, + ]; + + // Black key positions relative to white key index (0-6) + const blackKeys = [ + { note: 1, name: 'C#', after: 0 }, + { note: 3, name: 'D#', after: 1 }, + { note: 6, name: 'F#', after: 3 }, + { note: 8, name: 'G#', after: 4 }, + { note: 10, name: 'A#', after: 5 }, + ]; + + return ( +
+
+ + + Octave {oct} + +
+
+
+ {whiteKeys.map((k, i) => ( +
play(k.note)} + onPointerUp={() => stop(k.note)} + onPointerLeave={() => stop(k.note)} + onPointerCancel={() => stop(k.note)} + > + {k.name}{oct} +
+ ))} + {blackKeys.map((k) => ( +
play(k.note)} + onPointerUp={() => stop(k.note)} + onPointerLeave={() => stop(k.note)} + onPointerCancel={() => stop(k.note)} + > + {k.name} +
+ ))} +
+
+ ); +} + +export default function KeyboardWidget({ moduleId, fullscreen, onCloseFullscreen }) { const mod = state.modules.find(m => m.id === moduleId); const octave = mod?.params?.octave ?? 4; const activeKeys = useRef(new Set()); @@ -47,7 +127,6 @@ export default function KeyboardWidget({ moduleId }) { if (activeKeys.current.size === 0) stopNote(); } }; - window.addEventListener('keydown', handleDown); window.addEventListener('keyup', handleUp); return () => { @@ -56,36 +135,47 @@ export default function KeyboardWidget({ moduleId }) { }; }, [playNote, stopNote]); - // Draw mini keyboard (1 octave) + // Mini keyboard (1 octave) const whites = [0, 2, 4, 5, 7, 9, 11]; const blacks = [1, 3, -1, 6, 8, 10]; return ( -
- - {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} + /> + ); + })} + +
+ Z-M / Q-I keys ยท Oct {octave} +
-
+ + {fullscreen && createPortal( + , + document.body + )} + ); } 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/components/ModuleNode.jsx b/src/components/ModuleNode.jsx index 9116795..3f171e0 100644 --- a/src/components/ModuleNode.jsx +++ b/src/components/ModuleNode.jsx @@ -1,10 +1,11 @@ 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'; +import DrumPadWidget from './DrumPadWidget.jsx'; import SequencerWidget from './SequencerWidget.jsx'; import PianoRollWidget from './PianoRollWidget.jsx'; @@ -45,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 }; @@ -58,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); @@ -69,7 +71,12 @@ export default function ModuleNode({ mod, zoom, onStartConnect, onPortPosition } return; } + let frameCount = 0; const tick = () => { + frameCount++; + rafRef.current = requestAnimationFrame(tick); + if (frameCount % 4 !== 0) return; + const t = performance.now() / 1000 - startTimeRef.current; const newValues = {}; @@ -79,30 +86,37 @@ 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); - rafRef.current = requestAnimationFrame(tick); }; rafRef.current = requestAnimationFrame(tick); @@ -176,6 +190,13 @@ export default function ModuleNode({ mod, zoom, onStartConnect, onPortPosition }
{def.icon} {def.name} + {(mod.type === 'keyboard' || mod.type === 'drumpad') && ( + + )}
@@ -246,7 +267,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' && setFullscreen(false)} />} {/* Sequencer widget */} {mod.type === 'sequencer' && } diff --git a/src/components/PianoRollWidget.jsx b/src/components/PianoRollWidget.jsx index 9e22f36..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 } 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']; @@ -90,11 +90,10 @@ 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 [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 +195,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 +221,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(() => { @@ -233,83 +233,76 @@ 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; - } - setPlayPos(-1); + unsubscribeTick(`pr-${moduleId}`); + playPosRef.current = -1; 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 + let currentNote = null; + let lastQuantPos = -1; - const clock = new Tone.Clock(() => { - const rawPos = tickCount * 0.25; // position in beats (each tick = 1 sixteenth = 0.25 beats) - const pos = loop ? rawPos % totalBeats : rawPos; - const prevRawPos = (tickCount - 1) * 0.25; - const prevPos = loop ? prevRawPos % totalBeats : prevRawPos; - tickCount++; + subscribeTick(`pr-${moduleId}`, (time, ticks) => { + const currentBpm = bpmRef.current; + const currentLoop = loopRef.current; + const currentTotalBeats = totalBeatsRef.current; - // Detect loop wrap (position jumped backwards) - const looped = tickCount > 1 && pos < prevPos; + // 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; - // Stop at end if not looping - if (!loop && rawPos >= totalBeats) { + if (quantPos === lastQuantPos) return; + const looped = lastQuantPos >= 0 && quantPos < lastQuantPos; + lastQuantPos = quantPos; + + if (!currentLoop && rawPos >= currentTotalBeats) { if (currentNote) { setSequencerSignals(moduleId, 0, false); currentNote = null; } - setPlayPos(-1); + playPosRef.current = -1; return; } - setPlayPos(pos); + 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); + const activeNote = allNotes.find(n => quantPos >= n.start && quantPos < 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; } } - }, sixteenthRate); - - 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/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/components/SequencerWidget.jsx b/src/components/SequencerWidget.jsx index 437a5b8..3bf0c10 100644 --- a/src/components/SequencerWidget.jsx +++ b/src/components/SequencerWidget.jsx @@ -1,14 +1,13 @@ 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, MASTER_TICK_RATE } from '../engine/audioEngine.js'; const NOTE_NAMES = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B']; 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,12 @@ const DEFAULT_STEPS = [ export default function SequencerWidget({ moduleId }) { const mod = state.modules.find(m => m.id === moduleId); - const [currentStep, setCurrentStep] = useState(-1); - const clockRef = useRef(null); + const currentStepRef = useRef(-1); + const [visualStep, setVisualStep] = useState(-1); 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 +30,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,54 +42,69 @@ 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]); + + // 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; - } - setCurrentStep(-1); + unsubscribeTick(`seq-${moduleId}`); + currentStepRef.current = -1; + setVisualStep(-1); return; } - // Independent clock at 16th-note rate - const sixteenthRate = (bpm * 4) / 60; // Hz - let step = 0; + let lastStepIdx = -1; + let lastGateOn = false; + + subscribeTick(`seq-${moduleId}`, (time, ticks) => { + const currentBpm = bpmRef.current; + const currentNumSteps = numStepsRef.current; + // 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; + + // Turn off previous note at step boundary (no setTimeout needed) + if (lastGateOn) { + setSequencerSignals(moduleId, 0, false); + lastGateOn = false; + } - const clock = new Tone.Clock((time) => { - const stepIdx = step % numSteps; - step++; const s = stepsRef.current[stepIdx]; if (!s) return; - setCurrentStep(stepIdx); + 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(() => { - setSequencerSignals(moduleId, midiToFreq(s.midi), false); - }, stepDuration * 0.8 * 1000); - } else { - setSequencerSignals(moduleId, midiToFreq(s.midi), false); + lastGateOn = true; } - }, sixteenthRate); - - 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 }; @@ -115,20 +128,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 +169,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 a1af47e..0894599 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 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 = 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) + +export function subscribeTick(id, callback) { + _tickListeners.set(id, callback); +} + +export function unsubscribeTick(id) { + _tickListeners.delete(id); +} + +function startMasterClock() { + if (_masterClock) return; + let _startTime = 0; + let _started = false; + _masterClock = new Tone.Clock((time) => { + 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, ticks); + } + }, 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) { @@ -84,13 +126,17 @@ function createNode(mod) { }; } case 'vca': { - // Use a Multiply node: in ร— cv const gain = 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, - 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': { @@ -145,6 +191,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); @@ -170,7 +230,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 +312,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 } @@ -267,6 +328,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) { @@ -285,6 +351,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) { @@ -329,7 +401,12 @@ 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') { + // 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; + // cvMod stays at 1 always โ€” envelope controls full range + } break; case 'delay': if (paramName === 'delayTime') entry.node.delayTime.value = value; @@ -354,6 +431,8 @@ export function updateParam(moduleId, paramName, value) { if (paramName === 'volume') entry.node.gain.value = Tone.dbToGain(value); break; case 'keyboard': + case 'drumpad': + case 'cv2gate': case 'sequencer': case 'pianoroll': // All params stored in state, managed by widgets @@ -361,31 +440,48 @@ export function updateParam(moduleId, paramName, value) { } } +// Cache connection lookups for hot-path audio scheduling +// 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) { + 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); + } + _connCacheDirty = false; + } + 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(); } } } @@ -396,25 +492,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(); } } } @@ -422,18 +513,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)) { @@ -458,6 +552,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) { @@ -472,6 +575,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 0982902..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' }, }, }); @@ -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', { @@ -258,6 +275,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/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/game/PuzzleView.jsx b/src/game/PuzzleView.jsx index 32ce0d4..51c5c31 100644 --- a/src/game/PuzzleView.jsx +++ b/src/game/PuzzleView.jsx @@ -4,6 +4,9 @@ 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 { 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'; @@ -20,6 +23,13 @@ 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'); + + // 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(() => { @@ -49,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(); }; @@ -127,10 +140,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) { @@ -335,7 +355,7 @@ export default function PuzzleView({ level, levelIndex, worldLevels, onBack, onN
{/* Top bar */}
- +
{levelIndex + 1}/{worldLevels.length} {level.title} @@ -345,19 +365,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 +524,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/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 bfce7b0..1a18d20 100644 --- a/src/index.css +++ b/src/index.css @@ -30,7 +30,14 @@ 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; } +/* 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; } @@ -117,6 +124,14 @@ html, body, #root { } .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 */ @@ -785,3 +800,366 @@ 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); } + +/* ===== Fullscreen Keyboard ===== */ +.keyboard-fullscreen { + position: fixed; inset: 0; z-index: 500; + background: #050510; display: flex; flex-direction: column; + 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: 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-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; + touch-action: none; user-select: none; +} +.keyboard-fs-white { + 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: 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-note-label { + 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: 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.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 { + 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 + ============================================ */ + +/* --- Bottom Sheet --- */ +.bottom-sheet { + display: none; +} + +/* --- Mobile Tab Bar --- */ +.mobile-tab-bar { + display: none; +} + +@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; } + .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; + transition: max-height 0.3s ease; + overflow: hidden; + } + .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; 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; + 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; touch-action: none; } + .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; } + +} diff --git a/src/main.jsx b/src/main.jsx index fadf959..5ad89a6 100644 --- a/src/main.jsx +++ b/src/main.jsx @@ -15,3 +15,23 @@ function Root() { } createRoot(document.getElementById('root')).render(); + +// 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(() => {}); + } + 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', () => { + navigator.serviceWorker.register('/sw.js').catch(() => {}); + }); +}