Compare commits

...

23 Commits

Author SHA1 Message Date
Jose Luis
4f4d2bfae5 Merge feat/mobile-ui: responsive mobile UI, audio engine fixes, new modules
Mobile UI:
- Responsive layout for all views (Sandbox, World Map, Puzzle View)
- Bottom sheet with swipe gestures for module palette and puzzle tabs
- Mobile tab bar navigation (Game/Sandbox/Config)
- Touch panning, pinch-to-zoom, native zoom blocking
- PWA support (installable, offline-capable)

New modules:
- Drum Pad (🥁): 4x4 colored pad grid with gate/freq output
- CV→Gate (): converts continuous CV to gate signal with threshold
- Fullscreen mode for Keyboard and Drum Pad (portal-based)

Audio engine:
- Global master clock (120 Hz) with time-derived ticks (no drift)
- Connection cache with dirty flag (zero overhead on cache hit)
- Reduced main thread pressure (throttled RAF loops, lower clock rate)
- VCA properly zeroes with CV control, envelope release min 0.001s
- Audio context unlocked on first interaction for immediate UI sounds
2026-03-21 19:12:17 +01:00
Jose Luis
02db83b896 fix: VCA CV scaler always 1 so envelope works regardless of gain param
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) <noreply@anthropic.com>
2026-03-21 18:55:29 +01:00
Jose Luis
49c016d0a6 fix: prevent envelope release=0 causing sustain loop bug
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) <noreply@anthropic.com>
2026-03-21 18:51:42 +01:00
Jose Luis
2a2b3b3341 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) <noreply@anthropic.com>
2026-03-21 18:50:28 +01:00
Jose Luis
38dca9402f fix: VCA closes properly with envelope + add CV→Gate module
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) <noreply@anthropic.com>
2026-03-21 18:44:28 +01:00
Jose Luis
7e6c960b0b fix: reduce main thread pressure to prevent audio buffer underruns
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) <noreply@anthropic.com>
2026-03-21 18:19:12 +01:00
Jose Luis
7596aea491 fix: derive master clock ticks from AudioContext.currentTime
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) <noreply@anthropic.com>
2026-03-21 18:08:10 +01:00
Jose Luis
7d3a19ec35 fix: use integer tick counter to eliminate floating-point beat drift
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) <noreply@anthropic.com>
2026-03-21 18:01:56 +01:00
Jose Luis
8bdb953b52 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) <noreply@anthropic.com>
2026-03-21 17:57:10 +01:00
Jose Luis
18661961a1 fix: reduce master clock to 240 Hz + eliminate note-off timeouts
- 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) <noreply@anthropic.com>
2026-03-21 17:52:38 +01:00
Jose Luis
1f941d7e39 feat: global master clock for drift-free multi-sequencer timing
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) <noreply@anthropic.com>
2026-03-21 17:45:10 +01:00
Jose Luis
9dba156961 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) <noreply@anthropic.com>
2026-03-21 17:42:30 +01:00
Jose Luis
b91b35f23d fix: eliminate audio timing jitter and rhythm drift
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) <noreply@anthropic.com>
2026-03-21 17:35:41 +01:00
Jose Luis
1cf39f9b13 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) <noreply@anthropic.com>
2026-03-21 17:28:55 +01:00
Jose Luis
cf6e912905 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) <noreply@anthropic.com>
2026-03-21 16:31:12 +01:00
Jose Luis
52045897e5 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) <noreply@anthropic.com>
2026-03-21 16:24:33 +01:00
Jose Luis
8b193126f7 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) <noreply@anthropic.com>
2026-03-21 16:15:29 +01:00
Jose Luis
f0e7f7f37a fix: expand button for fullscreen + disable text selection
- 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) <noreply@anthropic.com>
2026-03-21 16:11:32 +01:00
Jose Luis
892195410b fix: proper fullscreen piano (1 octave, big keys) + block native zoom
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) <noreply@anthropic.com>
2026-03-21 16:08:10 +01:00
Jose Luis
816e7270ed feat: fullscreen keyboard + new Drum Pad module
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) <noreply@anthropic.com>
2026-03-21 16:02:22 +01:00
Jose Luis
323f30cfb9 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) <noreply@anthropic.com>
2026-03-21 15:53:28 +01:00
Jose Luis
8b66944e52 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) <noreply@anthropic.com>
2026-03-21 15:46:28 +01:00
Jose Luis
cd88fb5444 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) <noreply@anthropic.com>
2026-03-21 15:40:50 +01:00
23 changed files with 1408 additions and 227 deletions

View File

@@ -2,9 +2,14 @@
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
<title>Reaktor — MontLab Modular Synth</title>
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="manifest" href="/manifest.json" />
<meta name="theme-color" content="#00e5ff" />
<link rel="apple-touch-icon" href="/icon-192.png" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
</head>
<body>
<div id="root"></div>

BIN
public/icon-192.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

BIN
public/icon-512.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

15
public/manifest.json Normal file
View File

@@ -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" }
]
}

33
public/sw.js Normal file
View File

@@ -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;
})
);
});

View File

@@ -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 (
<div className="app">
{/* Toolbar */}
<div className="toolbar">
{onSwitchToGame && (
{onSwitchToGame && !isMobile && (
<button className="toolbar-btn" onClick={onSwitchToGame} style={{ color: 'var(--yellow)' }}>
🎮 Game
</button>
)}
<span className="toolbar-title">Reaktor</span>
<div className="toolbar-sep" />
<button className={`toolbar-btn ${state.isRunning ? 'active' : ''}`} onClick={handleToggleAudio}>
{state.isRunning ? '⏹ Stop' : '▶ Start'}
</button>
<div className="toolbar-sep" />
<div className="toolbar-group">
<button className="toolbar-btn" onClick={() => setPresetModal('save')}>💾 Save</button>
<button className="toolbar-btn" onClick={() => setPresetModal('load')}>📂 Load</button>
<button className="toolbar-btn" onClick={exportPatch}>📤 Export</button>
<button className="toolbar-btn" onClick={() => importRef.current?.click()}>📥 Import</button>
<input ref={importRef} type="file" accept=".json" style={{ display: 'none' }} onChange={handleImport} />
</div>
<div className="toolbar-sep" />
<button className="toolbar-btn" onClick={handleLoadDemo} style={{ color: 'var(--yellow)' }}>
🎮 Chiptune Demo
</button>
<button className="toolbar-btn" onClick={handleClearCanvas}>
🗑 Limpiar
</button>
<div className="toolbar-sep" />
<span className="toolbar-label" style={{ color: state.isRunning ? 'var(--green)' : 'var(--text2)' }}>
{!isMobile && <div className="toolbar-sep" />}
{!isMobile && (
<button className={`toolbar-btn start-btn ${state.isRunning ? 'active' : ''}`} onClick={handleToggleAudio}>
{state.isRunning ? '⏹ Stop' : '▶ Start'}
</button>
)}
{!isMobile && <div className="toolbar-sep" />}
{!isMobile && (
<div className="toolbar-group">
<button className="toolbar-btn save-btn" onClick={() => setPresetModal('save')}>💾 Save</button>
<button className="toolbar-btn load-btn" onClick={() => setPresetModal('load')}>📂 Load</button>
<button className="toolbar-btn export-btn" onClick={exportPatch}>📤 Export</button>
<button className="toolbar-btn import-btn" onClick={() => importRef.current?.click()}>📥 Import</button>
<input ref={importRef} type="file" accept=".json" style={{ display: 'none' }} onChange={handleImport} />
</div>
)}
{!isMobile && <div className="toolbar-sep" />}
{!isMobile && (
<>
<button className="toolbar-btn demo-btn" onClick={handleLoadDemo} style={{ color: 'var(--yellow)' }}>
🎮 Chiptune Demo
</button>
<button className="toolbar-btn clear-btn" onClick={handleClearCanvas}>
🗑 Limpiar
</button>
<div className="toolbar-sep" />
</>
)}
<span className="toolbar-label" style={{ color: state.isRunning ? 'var(--green)' : 'var(--text2)', marginLeft: isMobile ? 'auto' : undefined }}>
{state.isRunning ? '● LIVE' : '○ OFF'}
</span>
<span className="toolbar-label" style={{ marginLeft: 'auto' }}>
{state.modules.length} modules · {state.connections.length} wires
</span>
{!isMobile && (
<span className="toolbar-label" style={{ marginLeft: 'auto' }}>
{state.modules.length} modules · {state.connections.length} wires
</span>
)}
{isMobile && (
<button className="mobile-menu-btn" onClick={() => setMenuOpen(true)}></button>
)}
</div>
{/* Mobile menu overlay */}
{isMobile && menuOpen && (
<div className="mobile-menu-overlay" onClick={() => setMenuOpen(false)}>
<div className="mobile-menu-panel" onClick={e => e.stopPropagation()}>
{onSwitchToGame && (
<button className="toolbar-btn" onClick={() => { setMenuOpen(false); onSwitchToGame(); }} style={{ color: 'var(--yellow)' }}>
🎮 Game
</button>
)}
<button className="toolbar-btn" onClick={() => { setPresetModal('save'); setMenuOpen(false); }}>💾 Save</button>
<button className="toolbar-btn" onClick={() => { setPresetModal('load'); setMenuOpen(false); }}>📂 Load</button>
<button className="toolbar-btn" onClick={() => { exportPatch(); setMenuOpen(false); }}>📤 Export</button>
<button className="toolbar-btn" onClick={() => { importRef.current?.click(); setMenuOpen(false); }}>📥 Import</button>
<input ref={importRef} type="file" accept=".json" style={{ display: 'none' }} onChange={handleImport} />
<button className="toolbar-btn" onClick={() => { handleLoadDemo(); setMenuOpen(false); }} style={{ color: 'var(--yellow)' }}>
🎮 Chiptune Demo
</button>
<button className="toolbar-btn" onClick={() => { handleClearCanvas(); setMenuOpen(false); }}>🗑 Limpiar</button>
</div>
</div>
)}
{/* Main canvas area */}
<div className="main-area">
<div
@@ -310,10 +366,10 @@ export default function App({ onSwitchToGame }) {
<rect width="100%" height="100%" fill="url(#grid)" />
</svg>
{/* Wire layer (behind modules, uses getBoundingClientRect) */}
{/* Wire layer */}
<WireLayer portPositions={portPositions} tempWire={tempWire} containerRef={containerRef} zoom={state.zoom} camX={state.camX} camY={state.camY} />
{/* Modules container (offset by camera) */}
{/* Modules container */}
<div style={{ position: 'absolute', left: state.camX, top: state.camY, zIndex: 2 }}>
{state.modules.map(mod => (
<ModuleNode
@@ -327,7 +383,7 @@ export default function App({ onSwitchToGame }) {
</div>
</div>
{/* Zoom controls — top right of canvas */}
{/* Zoom controls */}
<div className="zoom-controls">
<button className="zoom-btn" onClick={handleZoomIn} title="Zoom in">+</button>
<button className="zoom-btn zoom-label" onClick={handleZoomReset} title="Reset zoom">
@@ -337,11 +393,40 @@ export default function App({ onSwitchToGame }) {
<button className="zoom-btn" onClick={handleCenterView} title="Centrar vista"></button>
</div>
{/* Module palette */}
<ModulePalette onAddModule={handleAddModule} />
{/* Desktop palette */}
{!isMobile && <ModulePalette onAddModule={handleAddModule} />}
</div>
{/* Status bar */}
{/* Mobile action bar */}
{isMobile && (
<div className="mobile-action-bar">
<button
className={`start-btn-mobile ${state.isRunning ? 'active' : ''}`}
onClick={handleToggleAudio}
>
{state.isRunning ? '⏹ STOP' : '▶ START'}
</button>
<button className="action-icon-btn" onClick={() => setPresetModal('save')}>💾</button>
<button className="action-icon-btn" onClick={exportPatch}>📤</button>
<button className="action-icon-btn" onClick={handleClearCanvas}>🗑</button>
</div>
)}
{/* Mobile bottom sheet with modules */}
{isMobile && (
<BottomSheet>
<div className="mobile-module-grid">
{allModuleDefs.map(def => (
<div key={def.type} className="mobile-module-tile" onClick={() => handleAddModule(def.type)}>
<span className="tile-icon">{def.icon}</span>
<span className="tile-name">{def.name}</span>
</div>
))}
</div>
</BottomSheet>
)}
{/* Status bar (hidden on mobile via CSS) */}
<div className="status-bar">
<span className="status-accent">Reaktor MontLab Modular Synth</span>
<span>Zoom: {(state.zoom * 100).toFixed(0)}%</span>

View File

@@ -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 (
<div
className={`bottom-sheet ${expanded ? 'expanded' : 'collapsed'} ${className}`}
onTouchStart={handleTouchStart}
onTouchEnd={handleTouchEnd}
>
<div className="bottom-sheet-handle" onClick={() => setExpanded(v => !v)}>
<div className="bottom-sheet-handle-bar" />
{!expanded && !tabs && (
<span className="bottom-sheet-peek-label">Modulos </span>
)}
</div>
{tabs && tabs.length > 0 && (
<div className="bottom-sheet-tabs" onClick={() => !expanded && setExpanded(true)}>
{tabs.map(tab => (
<button
key={tab.id}
className={`bottom-sheet-tab ${activeTab === tab.id ? 'active' : ''}`}
onClick={() => { onTabChange?.(tab.id); setExpanded(true); }}
>
{tab.label}
{activeTab === tab.id && <div className="bottom-sheet-tab-line" />}
</button>
))}
</div>
)}
{expanded && (
<div className="bottom-sheet-content">
{children}
</div>
)}
</div>
);
}

View File

@@ -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 (
<div className="drumpad-fullscreen">
<div className="drumpad-fs-header">
<span className="drumpad-fs-title">🥁 Drum Pads</span>
<button className="drumpad-fs-close" onClick={onClose}></button>
</div>
<div className="drumpad-fs-grid">
{PAD_NOTES.map((pad, i) => (
<div
key={i}
className="drumpad-fs-pad"
style={{
background: activePad === i ? pad.color : `${pad.color}15`,
borderColor: activePad === i ? pad.color : `${pad.color}40`,
color: activePad === i ? '#000' : pad.color,
}}
onPointerDown={() => hitPad(pad, i)}
>
{pad.label}
<span className="pad-label">{i + 1}</span>
</div>
))}
</div>
</div>
);
}
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 (
<>
<div>
<div className="drumpad-grid">
{PAD_NOTES.map((pad, i) => (
<div
key={i}
className={`drumpad-pad ${activePad === i ? 'active' : ''}`}
style={{
background: activePad === i ? pad.color : `${pad.color}15`,
borderColor: `${pad.color}60`,
}}
onPointerDown={(e) => { e.stopPropagation(); hitPad(pad, i); }}
>
{pad.label}
</div>
))}
</div>
<div style={{ fontSize: 9, color: 'var(--text2)', textAlign: 'center', marginTop: 2 }}>
Tap pads to trigger
</div>
</div>
{fullscreen && createPortal(
<FullscreenDrumPad moduleId={moduleId} onClose={onCloseFullscreen} />,
document.body
)}
</>
);
}

View File

@@ -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 (
<div className="keyboard-fullscreen">
<div className="keyboard-fs-header">
<button className="keyboard-fs-close" onClick={onClose}></button>
<button className="keyboard-fs-oct-btn" onClick={() => setOct(o => Math.max(1, o - 1))}></button>
<span className="keyboard-fs-title">Octave {oct}</span>
<button className="keyboard-fs-oct-btn" onClick={() => setOct(o => Math.min(8, o + 1))}></button>
<div style={{ width: 36 }} />
</div>
<div className="keyboard-fs-keys">
{whiteKeys.map((k, i) => (
<div
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)}
>
<span className="keyboard-fs-note-label">{k.name}{oct}</span>
</div>
))}
{blackKeys.map((k) => (
<div
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)}
>
<span className="keyboard-fs-black-label">{k.name}</span>
</div>
))}
</div>
</div>
);
}
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 (
<div style={{ padding: '2px 0' }}>
<svg viewBox="0 0 140 30" style={{ width: '100%', height: 30 }}>
{whites.map((note, i) => (
<rect key={`w${i}`} x={i * 20} y={0} width={19} height={28}
rx={1} fill="#222" stroke="#444" strokeWidth={0.5}
style={{ cursor: 'pointer' }}
onPointerDown={() => playNote(note)}
onPointerUp={stopNote}
/>
))}
{blacks.filter(n => n >= 0).map((note, i) => {
const pos = [1, 2, 4, 5, 6][i];
return (
<rect key={`b${i}`} x={pos * 20 - 6} y={0} width={12} height={18}
rx={1} fill="#111" stroke="#333" strokeWidth={0.5}
<>
<div style={{ padding: '2px 0' }}>
<svg viewBox="0 0 140 30" style={{ width: '100%', height: 30 }}>
{whites.map((note, i) => (
<rect key={`w${i}`} x={i * 20} y={0} width={19} height={28}
rx={1} fill="#222" stroke="#444" strokeWidth={0.5}
style={{ cursor: 'pointer' }}
onPointerDown={() => playNote(note)}
onPointerUp={stopNote}
/>
);
})}
</svg>
<div style={{ fontSize: 9, color: 'var(--text2)', textAlign: 'center', marginTop: 2 }}>
Z-M / Q-I keys · Oct {octave}
))}
{blacks.filter(n => n >= 0).map((note, i) => {
const pos = [1, 2, 4, 5, 6][i];
return (
<rect key={`b${i}`} x={pos * 20 - 6} y={0} width={12} height={18}
rx={1} fill="#111" stroke="#333" strokeWidth={0.5}
style={{ cursor: 'pointer' }}
onPointerDown={() => playNote(note)}
onPointerUp={stopNote}
/>
);
})}
</svg>
<div style={{ fontSize: 9, color: 'var(--text2)', textAlign: 'center', marginTop: 2 }}>
Z-M / Q-I keys · Oct {octave}
</div>
</div>
</div>
{fullscreen && createPortal(
<FullscreenPiano
moduleId={moduleId}
initialOctave={octave}
onClose={onCloseFullscreen}
/>,
document.body
)}
</>
);
}

View File

@@ -0,0 +1,16 @@
export default function MobileTabBar({ tabs, activeTab, onTabChange }) {
return (
<nav className="mobile-tab-bar">
{tabs.map(tab => (
<button
key={tab.id}
className={`mobile-tab ${activeTab === tab.id ? 'active' : ''}`}
onClick={() => onTabChange(tab.id)}
>
<span className="mobile-tab-icon">{tab.icon}</span>
<span className="mobile-tab-label">{tab.label}</span>
</button>
))}
</nav>
);
}

View File

@@ -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 }
<div className="module-header" onPointerDown={handleHeaderDown}>
<span className="type-icon">{def.icon}</span>
<span className="type-name">{def.name}</span>
{(mod.type === 'keyboard' || mod.type === 'drumpad') && (
<button
className="expand-btn"
onClick={(e) => { e.stopPropagation(); setFullscreen(true); }}
title="Pantalla completa"
></button>
)}
<button className="close-btn" onClick={handleDelete}></button>
</div>
@@ -246,7 +267,10 @@ export default function ModuleNode({ mod, zoom, onStartConnect, onPortPosition }
{mod.type === 'scope' && <ScopeDisplay moduleId={mod.id} />}
{/* Keyboard widget */}
{mod.type === 'keyboard' && <KeyboardWidget moduleId={mod.id} />}
{mod.type === 'keyboard' && <KeyboardWidget moduleId={mod.id} fullscreen={fullscreen} onCloseFullscreen={() => setFullscreen(false)} />}
{/* Drum Pad widget */}
{mod.type === 'drumpad' && <DrumPadWidget moduleId={mod.id} fullscreen={fullscreen} onCloseFullscreen={() => setFullscreen(false)} />}
{/* Sequencer widget */}
{mod.type === 'sequencer' && <SequencerWidget moduleId={mod.id} />}

View File

@@ -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) => {

View File

@@ -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();

View File

@@ -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 (
<div style={{ width: W + 4, overflow: 'hidden' }}>
<svg viewBox={`0 0 ${W} ${H}`} style={{ width: W, height: H, display: 'block' }}>
{/* 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 (
<g key={i}>
{/* Background */}
<rect x={x + 1} y={0} width={CELL_W - 2} height={CELL_H}
rx={2} fill={isActive ? '#1a2a40' : '#0c0c18'}
stroke={isActive ? '#00e5ff' : '#222'} strokeWidth={isActive ? 1.5 : 0.5}
/>
{/* Note bar */}
{s.gate && (
<rect x={x + 3} y={CELL_H - barHeight - 2} width={CELL_W - 6} height={barHeight}
rx={1}
@@ -136,17 +146,14 @@ export default function SequencerWidget({ moduleId }) {
opacity={0.9}
/>
)}
{/* Inactive marker */}
{!s.gate && (
<line x1={x + CELL_W / 2} y1={CELL_H / 2 - 3} x2={x + CELL_W / 2} y2={CELL_H / 2 + 3}
stroke="#333" strokeWidth={1.5} />
)}
{/* Note name */}
<text x={x + CELL_W / 2} y={CELL_H + 10} textAnchor="middle"
fontSize={6} fill={s.gate ? '#88ccff' : '#333'} fontFamily="monospace">
{noteLabel(s.midi)}
</text>
{/* Click areas: top half = note up, bottom half = note down, middle = toggle gate */}
<rect x={x} y={0} width={CELL_W} height={CELL_H / 3}
fill="transparent" style={{ cursor: 'pointer' }}
onClick={() => changeNote(i, 1)}
@@ -162,11 +169,10 @@ export default function SequencerWidget({ moduleId }) {
</g>
);
})}
{/* Playhead line */}
{currentStep >= 0 && (
{visualStep >= 0 && (
<line
x1={currentStep * CELL_W + CELL_W / 2} y1={0}
x2={currentStep * CELL_W + CELL_W / 2} y2={CELL_H}
x1={visualStep * CELL_W + CELL_W / 2} y1={0}
x2={visualStep * CELL_W + CELL_W / 2} y2={CELL_H}
stroke="#00e5ff" strokeWidth={1} opacity={0.4}
/>
)}

View File

@@ -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) {

View File

@@ -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', {

View File

@@ -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();
}

View File

@@ -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
<div className="gm-puzzle">
{/* Top bar */}
<div className="gm-puzzle-bar">
<button className="gm-btn icon" onClick={() => { playNav(); onBack(); }}> Mapa</button>
<button className="gm-btn icon" onClick={() => { playNav(); onBack(); }}>{isMobile ? '←' : '← Mapa'}</button>
<div className="gm-puzzle-title">
<span className="gm-puzzle-num">{levelIndex + 1}/{worldLevels.length}</span>
<span className="gm-puzzle-name">{level.title}</span>
@@ -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 && <span className="btn-label">{targetPlaying ? ' Parar' : ' Objetivo'}</span>}
</button>
<button
className={`gm-btn ${state.isRunning ? 'active' : ''}`}
onClick={handleToggleAudio}
>
{state.isRunning ? '⏹ Parar' : ' Mi Sonido'}
</button>
<button className="gm-btn clear" onClick={handleClearCanvas} title="Limpiar canvas">
🗑 Limpiar
{state.isRunning ? '⏹' : '▶'}{!isMobile && <span className="btn-label">{state.isRunning ? ' Parar' : ' Mi Sonido'}</span>}
</button>
{!isMobile && (
<button className="gm-btn clear" onClick={handleClearCanvas} title="Limpiar canvas">
🗑 Limpiar
</button>
)}
<button className="gm-btn check" onClick={handleCheck}>
Comprobar
{!isMobile && <span className="btn-label"> Comprobar</span>}
</button>
{adminMode && (
<button className="gm-btn admin-solve" onClick={handleAutoSolve} title="Admin: resolver nivel">
@@ -368,7 +390,7 @@ export default function PuzzleView({ level, levelIndex, worldLevels, onBack, onN
</div>
<div className="gm-puzzle-content">
{/* Left sidebar */}
{/* Left sidebar (desktop only — hidden on mobile via CSS) */}
<div className="gm-puzzle-sidebar">
{/* Description — always visible */}
<div className="gm-concept-panel">
@@ -502,6 +524,89 @@ export default function PuzzleView({ level, levelIndex, worldLevels, onBack, onN
</div>
</div>
{/* Mobile bottom sheet with tabs (replaces sidebar) */}
{isMobile && (
<BottomSheet
tabs={[
{ id: 'mission', label: 'MISION' },
{ id: 'objectives', label: 'OBJETIVOS' },
{ id: 'modules', label: 'MODULOS' },
]}
activeTab={mobileTab}
onTabChange={setMobileTab}
>
{mobileTab === 'mission' && (
<div>
<p className="puzzle-mission-text">{level.description}</p>
{!showHint ? (
<button className="puzzle-hint-btn" onClick={handleRevealHint}>
<span className="puzzle-hint-icon">💡</span>
<span className="puzzle-hint-label">Mostrar Pista</span>
<span className="puzzle-hint-penalty">max </span>
</button>
) : (
<div style={{ marginTop: 8, padding: '10px 12px', background: 'var(--surface)', borderRadius: 8, border: '1px solid var(--yellow)' }}>
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--yellow)', marginBottom: 6 }}>💡 Pista <span className="puzzle-hint-penalty">max </span></div>
<p style={{ fontSize: 12, color: 'var(--text)', lineHeight: 1.5 }}>{level.concept}</p>
</div>
)}
</div>
)}
{mobileTab === 'objectives' && (
<div>
{level.checks.map((check, i) => {
const passed = result?.checks?.[i]?.passed;
const cappedByStar = hintUsed && check.star === 3;
return (
<div key={i} className="puzzle-obj-item">
<span className="puzzle-obj-star">{'★'.repeat(check.star)}</span>
<span className="puzzle-obj-desc" style={passed === true ? { color: 'var(--green)' } : passed === false ? { color: 'var(--red)' } : {}}>
{check.desc}
{cappedByStar && ' 🔒'}
</span>
{passed === true && !cappedByStar && <span style={{ color: 'var(--green)', fontWeight: 700 }}></span>}
{passed === false && <span style={{ color: 'var(--red)', fontWeight: 700 }}></span>}
</div>
);
})}
{hintUsed && (
<div style={{ marginTop: 8, padding: '6px 8px', background: 'rgba(255,204,0,0.08)', borderRadius: 4, fontSize: 10, color: 'var(--yellow)' }}>
Pista usada maximo 2 estrellas (permanente).
</div>
)}
</div>
)}
{mobileTab === 'modules' && (
<div>
{level.availableModules.length > 0 ? (
<div className="mobile-module-grid" style={{ gridTemplateColumns: 'repeat(4, 1fr)' }}>
{level.availableModules.map(type => {
const def = getModuleDef(type);
if (!def) return null;
return (
<div key={type} className="mobile-module-tile" onClick={() => handleAddModule(type)}>
<span className="tile-icon">{def.icon}</span>
<span className="tile-name">{def.name}</span>
</div>
);
})}
</div>
) : (
<p style={{ fontSize: 12, color: 'var(--text2)' }}>No hay modulos extra disponibles para este nivel.</p>
)}
<button className="gm-btn danger" onClick={() => loadLevel(true)} style={{ width: '100%', marginTop: 12, justifyContent: 'center' }}>
Reiniciar Nivel
</button>
<button className="gm-btn clear" onClick={handleClearCanvas} style={{ width: '100%', marginTop: 6, justifyContent: 'center' }}>
🗑 Limpiar
</button>
</div>
)}
</BottomSheet>
)}
{/* Level complete overlay */}
{result && result.stars >= 1 && (
<LevelComplete

View File

@@ -1,4 +1,6 @@
import React, { useState, useRef } from 'react';
import MobileTabBar from '../components/MobileTabBar.jsx';
import { useIsMobile } from '../hooks/useIsMobile.js';
import { WORLD_1 } from './levels/world1.js';
import { WORLD_2 } from './levels/world2.js';
import { WORLD_3 } from './levels/world3.js';
@@ -39,11 +41,18 @@ function isWorldUnlocked(world) {
return getTotalStars() >= 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 && (
<MobileTabBar
tabs={MOBILE_TABS}
activeTab="game"
onTabChange={(id) => {
if (id === 'sandbox') onSandbox?.();
if (id === 'config') onAdmin?.();
}}
/>
)}
</div>
);
}

14
src/hooks/useIsMobile.js Normal file
View File

@@ -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;
}

48
src/hooks/usePinchZoom.js Normal file
View File

@@ -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]);
}

View File

@@ -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; }
}

View File

@@ -15,3 +15,23 @@ function Root() {
}
createRoot(document.getElementById('root')).render(<Root />);
// 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(() => {});
});
}