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
This commit is contained in:
Jose Luis
2026-03-21 19:12:17 +01:00
23 changed files with 1408 additions and 227 deletions

View File

@@ -2,9 +2,14 @@
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8" /> <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> <title>Reaktor — MontLab Modular Synth</title>
<link rel="icon" type="image/svg+xml" href="/favicon.svg" /> <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> </head>
<body> <body>
<div id="root"></div> <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 WireLayer from './components/WireLayer.jsx';
import ModulePalette from './components/ModulePalette.jsx'; import ModulePalette from './components/ModulePalette.jsx';
import PresetModal from './components/PresetModal.jsx'; import PresetModal from './components/PresetModal.jsx';
import BottomSheet from './components/BottomSheet.jsx';
import { CHIPTUNE_PRESET } from './presets/chiptune.js'; 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 }) { export default function App({ onSwitchToGame }) {
const [, forceUpdate] = useState(0); const [, forceUpdate] = useState(0);
@@ -18,6 +22,13 @@ export default function App({ onSwitchToGame }) {
const connectingRef = useRef(null); const connectingRef = useRef(null);
const [presetModal, setPresetModal] = useState(null); const [presetModal, setPresetModal] = useState(null);
const importRef = useRef(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 // Subscribe to state changes
useEffect(() => { useEffect(() => {
@@ -86,10 +97,17 @@ export default function App({ onSwitchToGame }) {
state.panStart = { x: e.clientX - state.camX, y: e.clientY - state.camY }; state.panStart = { x: e.clientX - state.camX, y: e.clientY - state.camY };
e.preventDefault(); e.preventDefault();
} else if (e.button === 0 && !connectingRef.current) { } 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; state.selectedModuleId = null;
emit(); emit();
} }
}, []); }, [isMobile]);
const handlePointerMove = useCallback((e) => { const handlePointerMove = useCallback((e) => {
if (state.panning && state.panStart) { if (state.panning && state.panStart) {
@@ -249,44 +267,82 @@ export default function App({ onSwitchToGame }) {
emit(); emit();
}; };
// Flatten all modules for mobile grid
const allModuleDefs = Object.values(getModulesByCategory()).flat();
return ( return (
<div className="app"> <div className="app">
{/* Toolbar */} {/* Toolbar */}
<div className="toolbar"> <div className="toolbar">
{onSwitchToGame && ( {onSwitchToGame && !isMobile && (
<button className="toolbar-btn" onClick={onSwitchToGame} style={{ color: 'var(--yellow)' }}> <button className="toolbar-btn" onClick={onSwitchToGame} style={{ color: 'var(--yellow)' }}>
🎮 Game 🎮 Game
</button> </button>
)} )}
<span className="toolbar-title">Reaktor</span> <span className="toolbar-title">Reaktor</span>
<div className="toolbar-sep" /> {!isMobile && <div className="toolbar-sep" />}
<button className={`toolbar-btn ${state.isRunning ? 'active' : ''}`} onClick={handleToggleAudio}> {!isMobile && (
{state.isRunning ? '⏹ Stop' : '▶ Start'} <button className={`toolbar-btn start-btn ${state.isRunning ? 'active' : ''}`} onClick={handleToggleAudio}>
</button> {state.isRunning ? '⏹ Stop' : '▶ Start'}
<div className="toolbar-sep" /> </button>
<div className="toolbar-group"> )}
<button className="toolbar-btn" onClick={() => setPresetModal('save')}>💾 Save</button> {!isMobile && <div className="toolbar-sep" />}
<button className="toolbar-btn" onClick={() => setPresetModal('load')}>📂 Load</button> {!isMobile && (
<button className="toolbar-btn" onClick={exportPatch}>📤 Export</button> <div className="toolbar-group">
<button className="toolbar-btn" onClick={() => importRef.current?.click()}>📥 Import</button> <button className="toolbar-btn save-btn" onClick={() => setPresetModal('save')}>💾 Save</button>
<input ref={importRef} type="file" accept=".json" style={{ display: 'none' }} onChange={handleImport} /> <button className="toolbar-btn load-btn" onClick={() => setPresetModal('load')}>📂 Load</button>
</div> <button className="toolbar-btn export-btn" onClick={exportPatch}>📤 Export</button>
<div className="toolbar-sep" /> <button className="toolbar-btn import-btn" onClick={() => importRef.current?.click()}>📥 Import</button>
<button className="toolbar-btn" onClick={handleLoadDemo} style={{ color: 'var(--yellow)' }}> <input ref={importRef} type="file" accept=".json" style={{ display: 'none' }} onChange={handleImport} />
🎮 Chiptune Demo </div>
</button> )}
<button className="toolbar-btn" onClick={handleClearCanvas}> {!isMobile && <div className="toolbar-sep" />}
🗑 Limpiar {!isMobile && (
</button> <>
<div className="toolbar-sep" /> <button className="toolbar-btn demo-btn" onClick={handleLoadDemo} style={{ color: 'var(--yellow)' }}>
<span className="toolbar-label" style={{ color: state.isRunning ? 'var(--green)' : 'var(--text2)' }}> 🎮 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'} {state.isRunning ? '● LIVE' : '○ OFF'}
</span> </span>
<span className="toolbar-label" style={{ marginLeft: 'auto' }}> {!isMobile && (
{state.modules.length} modules · {state.connections.length} wires <span className="toolbar-label" style={{ marginLeft: 'auto' }}>
</span> {state.modules.length} modules · {state.connections.length} wires
</span>
)}
{isMobile && (
<button className="mobile-menu-btn" onClick={() => setMenuOpen(true)}></button>
)}
</div> </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 */} {/* Main canvas area */}
<div className="main-area"> <div className="main-area">
<div <div
@@ -310,10 +366,10 @@ export default function App({ onSwitchToGame }) {
<rect width="100%" height="100%" fill="url(#grid)" /> <rect width="100%" height="100%" fill="url(#grid)" />
</svg> </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} /> <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 }}> <div style={{ position: 'absolute', left: state.camX, top: state.camY, zIndex: 2 }}>
{state.modules.map(mod => ( {state.modules.map(mod => (
<ModuleNode <ModuleNode
@@ -327,7 +383,7 @@ export default function App({ onSwitchToGame }) {
</div> </div>
</div> </div>
{/* Zoom controls — top right of canvas */} {/* Zoom controls */}
<div className="zoom-controls"> <div className="zoom-controls">
<button className="zoom-btn" onClick={handleZoomIn} title="Zoom in">+</button> <button className="zoom-btn" onClick={handleZoomIn} title="Zoom in">+</button>
<button className="zoom-btn zoom-label" onClick={handleZoomReset} title="Reset zoom"> <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> <button className="zoom-btn" onClick={handleCenterView} title="Centrar vista"></button>
</div> </div>
{/* Module palette */} {/* Desktop palette */}
<ModulePalette onAddModule={handleAddModule} /> {!isMobile && <ModulePalette onAddModule={handleAddModule} />}
</div> </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"> <div className="status-bar">
<span className="status-accent">Reaktor MontLab Modular Synth</span> <span className="status-accent">Reaktor MontLab Modular Synth</span>
<span>Zoom: {(state.zoom * 100).toFixed(0)}%</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 { triggerKeyboard } from '../engine/audioEngine.js';
import { state } from '../engine/state.js'; import { state } from '../engine/state.js';
const NOTE_NAMES = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B']; 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 = { const KEY_MAP = {
'z': 0, 's': 1, 'x': 2, 'd': 3, 'c': 4, 'v': 5, 'g': 6, '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, '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); 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 mod = state.modules.find(m => m.id === moduleId);
const octave = mod?.params?.octave ?? 4; const octave = mod?.params?.octave ?? 4;
const activeKeys = useRef(new Set()); const activeKeys = useRef(new Set());
@@ -47,7 +127,6 @@ export default function KeyboardWidget({ moduleId }) {
if (activeKeys.current.size === 0) stopNote(); if (activeKeys.current.size === 0) stopNote();
} }
}; };
window.addEventListener('keydown', handleDown); window.addEventListener('keydown', handleDown);
window.addEventListener('keyup', handleUp); window.addEventListener('keyup', handleUp);
return () => { return () => {
@@ -56,36 +135,47 @@ export default function KeyboardWidget({ moduleId }) {
}; };
}, [playNote, stopNote]); }, [playNote, stopNote]);
// Draw mini keyboard (1 octave) // Mini keyboard (1 octave)
const whites = [0, 2, 4, 5, 7, 9, 11]; const whites = [0, 2, 4, 5, 7, 9, 11];
const blacks = [1, 3, -1, 6, 8, 10]; const blacks = [1, 3, -1, 6, 8, 10];
return ( return (
<div style={{ padding: '2px 0' }}> <>
<svg viewBox="0 0 140 30" style={{ width: '100%', height: 30 }}> <div style={{ padding: '2px 0' }}>
{whites.map((note, i) => ( <svg viewBox="0 0 140 30" style={{ width: '100%', height: 30 }}>
<rect key={`w${i}`} x={i * 20} y={0} width={19} height={28} {whites.map((note, i) => (
rx={1} fill="#222" stroke="#444" strokeWidth={0.5} <rect key={`w${i}`} x={i * 20} y={0} width={19} height={28}
style={{ cursor: 'pointer' }} rx={1} fill="#222" stroke="#444" strokeWidth={0.5}
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}
style={{ cursor: 'pointer' }} style={{ cursor: 'pointer' }}
onPointerDown={() => playNote(note)} onPointerDown={() => playNote(note)}
onPointerUp={stopNote} onPointerUp={stopNote}
/> />
); ))}
})} {blacks.filter(n => n >= 0).map((note, i) => {
</svg> const pos = [1, 2, 4, 5, 6][i];
<div style={{ fontSize: 9, color: 'var(--text2)', textAlign: 'center', marginTop: 2 }}> return (
Z-M / Q-I keys · Oct {octave} <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>
</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 React, { useCallback, useState, useEffect, useRef } from 'react';
import { getModuleDef } from '../engine/moduleRegistry.js'; import { getModuleDef } from '../engine/moduleRegistry.js';
import { state, removeModule, updateModuleParam, isPortConnected, emit } from '../engine/state.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 Knob from './Knob.jsx';
import ScopeDisplay from './ScopeDisplay.jsx'; import ScopeDisplay from './ScopeDisplay.jsx';
import KeyboardWidget from './KeyboardWidget.jsx'; import KeyboardWidget from './KeyboardWidget.jsx';
import DrumPadWidget from './DrumPadWidget.jsx';
import SequencerWidget from './SequencerWidget.jsx'; import SequencerWidget from './SequencerWidget.jsx';
import PianoRollWidget from './PianoRollWidget.jsx'; import PianoRollWidget from './PianoRollWidget.jsx';
@@ -45,6 +46,7 @@ export default function ModuleNode({ mod, zoom, onStartConnect, onPortPosition }
if (!def) return null; if (!def) return null;
const isSelected = state.selectedModuleId === mod.id; const isSelected = state.selectedModuleId === mod.id;
const [fullscreen, setFullscreen] = useState(false);
// Merge default params // Merge default params
const params = { ...Object.fromEntries(Object.entries(def.params).map(([k, v]) => [k, v.default])), ...mod.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 [liveValues, setLiveValues] = useState({});
const rafRef = useRef(null); const rafRef = useRef(null);
const startTimeRef = useRef(performance.now() / 1000); const startTimeRef = useRef(performance.now() / 1000);
@@ -69,7 +71,12 @@ export default function ModuleNode({ mod, zoom, onStartConnect, onPortPosition }
return; return;
} }
let frameCount = 0;
const tick = () => { const tick = () => {
frameCount++;
rafRef.current = requestAnimationFrame(tick);
if (frameCount % 4 !== 0) return;
const t = performance.now() / 1000 - startTimeRef.current; const t = performance.now() / 1000 - startTimeRef.current;
const newValues = {}; const newValues = {};
@@ -79,30 +86,37 @@ export default function ModuleNode({ mod, zoom, onStartConnect, onPortPosition }
if (!paramName) continue; if (!paramName) continue;
const srcMod = state.modules.find(m => m.id === conn.from.moduleId); 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 if (srcMod.type === 'lfo') {
const lfoDef = getModuleDef('lfo'); // LFO: simulate waveform for smooth visual
const lfoP = { ...Object.fromEntries(Object.entries(lfoDef.params).map(([k, v]) => [k, v.default])), ...srcMod.params }; const lfoDef = getModuleDef('lfo');
const freq = lfoP.frequency; const lfoP = { ...Object.fromEntries(Object.entries(lfoDef.params).map(([k, v]) => [k, v.default])), ...srcMod.params };
const amp = lfoP.amplitude; const freq = lfoP.frequency;
const waveform = lfoP.waveform; const amp = lfoP.amplitude;
const phase = (t * freq) % 1; const waveform = lfoP.waveform;
const lfoVal = simulateLFO(waveform, phase) * amp; const phase = (t * freq) % 1;
const lfoVal = simulateLFO(waveform, phase) * amp;
// Compute modulated value (same scaling as audioEngine) const baseValue = params[paramName];
const baseValue = params[paramName]; let scale;
let scale; if (mod.type === 'oscillator' && paramName === 'frequency') scale = baseValue * 0.5;
if (mod.type === 'oscillator' && paramName === 'frequency') scale = baseValue * 0.5; else if (mod.type === 'filter' && paramName === 'frequency') scale = baseValue;
else if (mod.type === 'filter' && paramName === 'frequency') scale = baseValue; else if (mod.type === 'vca' && paramName === 'gain') scale = 1;
else if (mod.type === 'vca' && paramName === 'gain') scale = 1; else scale = baseValue || 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); setLiveValues(newValues);
rafRef.current = requestAnimationFrame(tick);
}; };
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}> <div className="module-header" onPointerDown={handleHeaderDown}>
<span className="type-icon">{def.icon}</span> <span className="type-icon">{def.icon}</span>
<span className="type-name">{def.name}</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> <button className="close-btn" onClick={handleDelete}></button>
</div> </div>
@@ -246,7 +267,10 @@ export default function ModuleNode({ mod, zoom, onStartConnect, onPortPosition }
{mod.type === 'scope' && <ScopeDisplay moduleId={mod.id} />} {mod.type === 'scope' && <ScopeDisplay moduleId={mod.id} />}
{/* Keyboard widget */} {/* 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 */} {/* Sequencer widget */}
{mod.type === 'sequencer' && <SequencerWidget moduleId={mod.id} />} {mod.type === 'sequencer' && <SequencerWidget moduleId={mod.id} />}

View File

@@ -1,7 +1,7 @@
import React, { useState, useEffect, useRef, useCallback } from 'react'; import React, { useState, useEffect, useRef, useCallback } from 'react';
import * as Tone from 'tone'; import * as Tone from 'tone';
import { state, updateModuleParam, emit } from '../engine/state.js'; 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'; import { parseMidi } from '../utils/midiParser.js';
const NOTE_NAMES = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B']; 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 }) { export default function PianoRollWidget({ moduleId }) {
const mod = state.modules.find(m => m.id === moduleId); const mod = state.modules.find(m => m.id === moduleId);
const canvasRef = useRef(null); const canvasRef = useRef(null);
const partRef = useRef(null);
const [playPos, setPlayPos] = useState(-1);
const [tool, setTool] = useState('draw'); // 'draw' | 'erase' const [tool, setTool] = useState('draw'); // 'draw' | 'erase'
const drawingRef = useRef(null); const drawingRef = useRef(null);
const rafRef = useRef(null); const rafRef = useRef(null);
const playPosRef = useRef(-1);
const midiInputRef = useRef(null); const midiInputRef = useRef(null);
const bpm = mod?.params?.bpm ?? 140; const bpm = mod?.params?.bpm ?? 140;
@@ -196,8 +195,9 @@ export default function PianoRollWidget({ moduleId }) {
} }
// Playhead // Playhead
if (playPos >= 0 && playPos < totalBeats) { const currentPlayPos = playPosRef.current;
const px = KEY_W + playPos * beatW; if (currentPlayPos >= 0 && currentPlayPos < totalBeats) {
const px = KEY_W + currentPlayPos * beatW;
ctx.strokeStyle = '#ff6644'; ctx.strokeStyle = '#ff6644';
ctx.lineWidth = 2; ctx.lineWidth = 2;
ctx.beginPath(); ctx.beginPath();
@@ -221,7 +221,7 @@ export default function PianoRollWidget({ moduleId }) {
ctx.fillStyle = 'rgba(0,229,255,0.3)'; ctx.fillStyle = 'rgba(0,229,255,0.3)';
ctx.fillRect(x, row * ROW_H, Math.max(nw, 2), ROW_H); ctx.fillRect(x, row * ROW_H, Math.max(nw, 2), ROW_H);
} }
}, [totalBeats, beatW, playPos, rollW]); }, [totalBeats, beatW, rollW]);
// Animation loop // Animation loop
useEffect(() => { useEffect(() => {
@@ -233,83 +233,76 @@ export default function PianoRollWidget({ moduleId }) {
return () => { if (rafRef.current) cancelAnimationFrame(rafRef.current); }; return () => { if (rafRef.current) cancelAnimationFrame(rafRef.current); };
}, [draw]); }, [draw]);
// Playback — uses independent Tone.Clock so multiple pianorolls/sequencers // Subscribe to global master clock for playback
// don't interfere with each other via the global Transport const bpmRef = useRef(bpm);
const loopRef = useRef(loop);
const totalBeatsRef = useRef(totalBeats);
bpmRef.current = bpm;
loopRef.current = loop;
totalBeatsRef.current = totalBeats;
useEffect(() => { useEffect(() => {
if (!state.isRunning) { if (!state.isRunning) {
if (partRef.current) { unsubscribeTick(`pr-${moduleId}`);
try { partRef.current.stop(); } catch {} playPosRef.current = -1;
try { partRef.current.dispose(); } catch {}
partRef.current = null;
}
setPlayPos(-1);
return; return;
} }
const sixteenthRate = (bpm * 4) / 60; // Hz: sixteenth notes per second let currentNote = null;
const sixteenthDur = 1 / sixteenthRate; // seconds per sixteenth note let lastQuantPos = -1;
let tickCount = 0;
let currentNote = null; // track currently sounding note for on/off transitions
const clock = new Tone.Clock(() => { subscribeTick(`pr-${moduleId}`, (time, ticks) => {
const rawPos = tickCount * 0.25; // position in beats (each tick = 1 sixteenth = 0.25 beats) const currentBpm = bpmRef.current;
const pos = loop ? rawPos % totalBeats : rawPos; const currentLoop = loopRef.current;
const prevRawPos = (tickCount - 1) * 0.25; const currentTotalBeats = totalBeatsRef.current;
const prevPos = loop ? prevRawPos % totalBeats : prevRawPos;
tickCount++;
// Detect loop wrap (position jumped backwards) // Convert ticks to beats: each beat = MASTER_TICK_RATE * 60 / bpm ticks
const looped = tickCount > 1 && pos < prevPos; // 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 (quantPos === lastQuantPos) return;
if (!loop && rawPos >= totalBeats) { const looped = lastQuantPos >= 0 && quantPos < lastQuantPos;
lastQuantPos = quantPos;
if (!currentLoop && rawPos >= currentTotalBeats) {
if (currentNote) { if (currentNote) {
setSequencerSignals(moduleId, 0, false); setSequencerSignals(moduleId, 0, false);
currentNote = null; currentNote = null;
} }
setPlayPos(-1); playPosRef.current = -1;
return; return;
} }
setPlayPos(pos); playPosRef.current = pos;
// Force note-off on loop boundary for clean retrigger
if (looped && currentNote) { if (looped && currentNote) {
setSequencerSignals(moduleId, 0, false); setSequencerSignals(moduleId, 0, false);
currentNote = null; currentNote = null;
} }
// Find the note active at this position
const allNotes = notesRef.current; const allNotes = notesRef.current;
const activeNote = allNotes.find(n => pos >= n.start && pos < n.start + n.duration); const activeNote = allNotes.find(n => quantPos >= n.start && quantPos < n.start + n.duration);
if (activeNote) { if (activeNote) {
// New note or different note → trigger
if (!currentNote || currentNote.note !== activeNote.note || currentNote.start !== activeNote.start) { if (!currentNote || currentNote.note !== activeNote.note || currentNote.start !== activeNote.start) {
setSequencerSignals(moduleId, midiToFreq(activeNote.note), true); setSequencerSignals(moduleId, midiToFreq(activeNote.note), true);
currentNote = activeNote; currentNote = activeNote;
} }
// Same note sustaining → do nothing
} else { } else {
// No note at this position → gate off
if (currentNote) { if (currentNote) {
setSequencerSignals(moduleId, 0, false); setSequencerSignals(moduleId, 0, false);
currentNote = null; currentNote = null;
} }
} }
}, sixteenthRate); });
clock.start();
partRef.current = clock;
return () => { return () => {
if (partRef.current) { unsubscribeTick(`pr-${moduleId}`);
try { partRef.current.stop(); } catch {}
try { partRef.current.dispose(); } catch {}
partRef.current = null;
}
}; };
}, [state.isRunning, moduleId, bpm, bars, loop]); }, [state.isRunning, moduleId]);
// Mouse interaction for drawing/erasing notes // Mouse interaction for drawing/erasing notes
const handleMouseDown = useCallback((e) => { const handleMouseDown = useCallback((e) => {

View File

@@ -22,7 +22,13 @@ export default function ScopeDisplay({ moduleId }) {
const w = canvas.width = 160; const w = canvas.width = 160;
const h = canvas.height = 60; const h = canvas.height = 60;
let frameCount = 0;
const draw = () => { 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.fillStyle = '#050510';
ctx.fillRect(0, 0, w, h); ctx.fillRect(0, 0, w, h);
@@ -58,7 +64,6 @@ export default function ScopeDisplay({ moduleId }) {
ctx.stroke(); ctx.stroke();
} }
rafRef.current = requestAnimationFrame(draw);
}; };
draw(); draw();

View File

@@ -1,14 +1,13 @@
import React, { useState, useEffect, useRef, useCallback } from 'react'; import React, { useState, useEffect, useRef, useCallback } from 'react';
import * as Tone from 'tone'; import * as Tone from 'tone';
import { state, updateModuleParam, emit } from '../engine/state.js'; 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']; 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 midiToFreq(midi) { return 440 * Math.pow(2, (midi - 69) / 12); }
function noteLabel(midi) { return NOTE_NAMES[midi % 12] + Math.floor(midi / 12 - 1); } function noteLabel(midi) { return NOTE_NAMES[midi % 12] + Math.floor(midi / 12 - 1); }
// Default notes: C minor pentatonic pattern
const DEFAULT_STEPS = [ const DEFAULT_STEPS = [
{ midi: 60, gate: true }, { midi: 63, gate: true }, { midi: 65, gate: false }, { midi: 67, gate: true }, { 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 }, { 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 }) { export default function SequencerWidget({ moduleId }) {
const mod = state.modules.find(m => m.id === moduleId); const mod = state.modules.find(m => m.id === moduleId);
const [currentStep, setCurrentStep] = useState(-1); const currentStepRef = useRef(-1);
const clockRef = useRef(null); const [visualStep, setVisualStep] = useState(-1);
const stepsRef = useRef(null); const stepsRef = useRef(null);
const rafRef = useRef(null);
// Init steps data — also grow/shrink when numSteps changes // Init steps data
const numSteps = parseInt(mod?.params?.steps || '16'); const numSteps = parseInt(mod?.params?.steps || '16');
if (mod) { if (mod) {
if (!mod.params._steps) { if (!mod.params._steps) {
@@ -30,12 +30,10 @@ export default function SequencerWidget({ moduleId }) {
while (initial.length < numSteps) initial.push({ midi: 60, gate: false }); while (initial.length < numSteps) initial.push({ midi: 60, gate: false });
mod.params._steps = initial; mod.params._steps = initial;
} else if (mod.params._steps.length < numSteps) { } else if (mod.params._steps.length < numSteps) {
// Grow: pad with empty steps
while (mod.params._steps.length < numSteps) { while (mod.params._steps.length < numSteps) {
mod.params._steps.push({ midi: 60, gate: false }); mod.params._steps.push({ midi: 60, gate: false });
} }
} else if (mod.params._steps.length > numSteps) { } else if (mod.params._steps.length > numSteps) {
// Shrink: truncate
mod.params._steps = mod.params._steps.slice(0, numSteps); mod.params._steps = mod.params._steps.slice(0, numSteps);
} }
} }
@@ -44,54 +42,69 @@ export default function SequencerWidget({ moduleId }) {
const bpm = mod?.params?.bpm ?? 140; const bpm = mod?.params?.bpm ?? 140;
// Start/stop sequencer when audio engine runs — uses independent Tone.Clock // Visual update loop — decoupled from audio, uses RAF
// so multiple sequencers don't interfere with each other via the global Transport 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(() => { useEffect(() => {
if (!state.isRunning) { if (!state.isRunning) {
if (clockRef.current) { unsubscribeTick(`seq-${moduleId}`);
try { clockRef.current.stop(); } catch {} currentStepRef.current = -1;
try { clockRef.current.dispose(); } catch {} setVisualStep(-1);
clockRef.current = null;
}
setCurrentStep(-1);
return; return;
} }
// Independent clock at 16th-note rate let lastStepIdx = -1;
const sixteenthRate = (bpm * 4) / 60; // Hz let lastGateOn = false;
let step = 0;
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]; const s = stepsRef.current[stepIdx];
if (!s) return; if (!s) return;
setCurrentStep(stepIdx); currentStepRef.current = stepIdx;
if (s.gate) { if (s.gate) {
setSequencerSignals(moduleId, midiToFreq(s.midi), true); setSequencerSignals(moduleId, midiToFreq(s.midi), true);
// Schedule note-off at 80% of step duration lastGateOn = true;
const stepDuration = 1 / sixteenthRate;
setTimeout(() => {
setSequencerSignals(moduleId, midiToFreq(s.midi), false);
}, stepDuration * 0.8 * 1000);
} else {
setSequencerSignals(moduleId, midiToFreq(s.midi), false);
} }
}, sixteenthRate); });
clock.start();
clockRef.current = clock;
return () => { return () => {
if (clockRef.current) { unsubscribeTick(`seq-${moduleId}`);
try { clockRef.current.stop(); } catch {}
try { clockRef.current.dispose(); } catch {}
clockRef.current = null;
}
}; };
}, [state.isRunning, moduleId, numSteps, bpm]); }, [state.isRunning, moduleId]);
const toggleGate = (idx) => { const toggleGate = (idx) => {
steps[idx] = { ...steps[idx], gate: !steps[idx].gate }; steps[idx] = { ...steps[idx], gate: !steps[idx].gate };
@@ -115,20 +128,17 @@ export default function SequencerWidget({ moduleId }) {
return ( return (
<div style={{ width: W + 4, overflow: 'hidden' }}> <div style={{ width: W + 4, overflow: 'hidden' }}>
<svg viewBox={`0 0 ${W} ${H}`} style={{ width: W, height: H, display: 'block' }}> <svg viewBox={`0 0 ${W} ${H}`} style={{ width: W, height: H, display: 'block' }}>
{/* Steps */}
{steps.slice(0, numSteps).map((s, i) => { {steps.slice(0, numSteps).map((s, i) => {
const x = i * CELL_W; const x = i * CELL_W;
const isActive = i === currentStep; const isActive = i === visualStep;
const barHeight = ((s.midi - 36) / 60) * (CELL_H - 4); const barHeight = ((s.midi - 36) / 60) * (CELL_H - 4);
return ( return (
<g key={i}> <g key={i}>
{/* Background */}
<rect x={x + 1} y={0} width={CELL_W - 2} height={CELL_H} <rect x={x + 1} y={0} width={CELL_W - 2} height={CELL_H}
rx={2} fill={isActive ? '#1a2a40' : '#0c0c18'} rx={2} fill={isActive ? '#1a2a40' : '#0c0c18'}
stroke={isActive ? '#00e5ff' : '#222'} strokeWidth={isActive ? 1.5 : 0.5} stroke={isActive ? '#00e5ff' : '#222'} strokeWidth={isActive ? 1.5 : 0.5}
/> />
{/* Note bar */}
{s.gate && ( {s.gate && (
<rect x={x + 3} y={CELL_H - barHeight - 2} width={CELL_W - 6} height={barHeight} <rect x={x + 3} y={CELL_H - barHeight - 2} width={CELL_W - 6} height={barHeight}
rx={1} rx={1}
@@ -136,17 +146,14 @@ export default function SequencerWidget({ moduleId }) {
opacity={0.9} opacity={0.9}
/> />
)} )}
{/* Inactive marker */}
{!s.gate && ( {!s.gate && (
<line x1={x + CELL_W / 2} y1={CELL_H / 2 - 3} x2={x + CELL_W / 2} y2={CELL_H / 2 + 3} <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} /> stroke="#333" strokeWidth={1.5} />
)} )}
{/* Note name */}
<text x={x + CELL_W / 2} y={CELL_H + 10} textAnchor="middle" <text x={x + CELL_W / 2} y={CELL_H + 10} textAnchor="middle"
fontSize={6} fill={s.gate ? '#88ccff' : '#333'} fontFamily="monospace"> fontSize={6} fill={s.gate ? '#88ccff' : '#333'} fontFamily="monospace">
{noteLabel(s.midi)} {noteLabel(s.midi)}
</text> </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} <rect x={x} y={0} width={CELL_W} height={CELL_H / 3}
fill="transparent" style={{ cursor: 'pointer' }} fill="transparent" style={{ cursor: 'pointer' }}
onClick={() => changeNote(i, 1)} onClick={() => changeNote(i, 1)}
@@ -162,11 +169,10 @@ export default function SequencerWidget({ moduleId }) {
</g> </g>
); );
})} })}
{/* Playhead line */} {visualStep >= 0 && (
{currentStep >= 0 && (
<line <line
x1={currentStep * CELL_W + CELL_W / 2} y1={0} x1={visualStep * CELL_W + CELL_W / 2} y1={0}
x2={currentStep * CELL_W + CELL_W / 2} y2={CELL_H} x2={visualStep * CELL_W + CELL_W / 2} y2={CELL_H}
stroke="#00e5ff" strokeWidth={1} opacity={0.4} stroke="#00e5ff" strokeWidth={1} opacity={0.4}
/> />
)} )}

View File

@@ -12,6 +12,48 @@ const audioNodes = {};
// Active keyboard state // Active keyboard state
const keyboardState = { frequency: 440, gate: false }; 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 ==================== // ==================== Node creation ====================
function createNode(mod) { function createNode(mod) {
@@ -84,13 +126,17 @@ function createNode(mod) {
}; };
} }
case 'vca': { case 'vca': {
// Use a Multiply node: in × cv
const gain = new Tone.Gain(p.gain); 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 { return {
node: gain, node: gain,
inputs: { in: gain, cv: gain.gain }, _cvMod: cvMod,
inputs: { in: gain, cv: cvMod },
outputs: { out: gain }, outputs: { out: gain },
dispose: () => gain.dispose(), dispose: () => { cvMod.disconnect(); cvMod.dispose(); gain.dispose(); },
}; };
} }
case 'delay': { case 'delay': {
@@ -145,6 +191,20 @@ function createNode(mod) {
dispose: () => analyser.dispose(), 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': { case 'output': {
// True stereo output: separate left/right channels → merge → master gain → destination // True stereo output: separate left/right channels → merge → master gain → destination
const leftGain = new Tone.Gain(1); 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 freqSig = new Tone.Signal(440);
const gateSig = new Tone.Signal(0); const gateSig = new Tone.Signal(0);
return { return {
@@ -251,7 +312,7 @@ export function connectWire(conn) {
// set the oscillator frequency directly when notes are played. // set the oscillator frequency directly when notes are played.
const fromMod = state.modules.find(m => m.id === conn.from.moduleId); const fromMod = state.modules.find(m => m.id === conn.from.moduleId);
const toMod = state.modules.find(m => m.id === conn.to.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') { conn.from.port === 'freq' && toMod?.type === 'oscillator' && conn.to.port === 'freq') {
return; // handled imperatively in triggerKeyboard / setSequencerSignals return; // handled imperatively in triggerKeyboard / setSequencerSignals
} }
@@ -267,6 +328,11 @@ export function connectWire(conn) {
} catch (e) { } catch (e) {
console.warn('connect error', 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) { export function disconnectWire(conn) {
@@ -285,6 +351,12 @@ export function disconnectWire(conn) {
} catch (e) { } catch (e) {
// Tone.js may throw if not connected // 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) { export function updateParam(moduleId, paramName, value) {
@@ -329,7 +401,12 @@ export function updateParam(moduleId, paramName, value) {
else if (paramName === 'release') entry.node.release = value; else if (paramName === 'release') entry.node.release = value;
break; break;
case 'vca': 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; break;
case 'delay': case 'delay':
if (paramName === 'delayTime') entry.node.delayTime.value = value; 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); if (paramName === 'volume') entry.node.gain.value = Tone.dbToGain(value);
break; break;
case 'keyboard': case 'keyboard':
case 'drumpad':
case 'cv2gate':
case 'sequencer': case 'sequencer':
case 'pianoroll': case 'pianoroll':
// All params stored in state, managed by widgets // 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) { export function setSequencerSignals(moduleId, freq, gate) {
const entry = audioNodes[moduleId]; const entry = audioNodes[moduleId];
if (!entry) return; if (!entry) return;
if (entry._freqSig) entry._freqSig.value = freq; if (entry._freqSig) entry._freqSig.value = freq;
if (entry._gateSig) entry._gateSig.value = gate ? 1 : 0; if (entry._gateSig) entry._gateSig.value = gate ? 1 : 0;
// Directly set connected oscillator frequencies (bypasses the modulation Gain) // Set connected oscillator frequencies directly
for (const conn of state.connections) { for (const conn of getConnectionsFrom(moduleId, 'freq')) {
if (conn.from.moduleId === moduleId && conn.from.port === 'freq') { const oscEntry = audioNodes[conn.to.moduleId];
const oscEntry = audioNodes[conn.to.moduleId]; if (oscEntry?.node?.frequency) {
const oscMod = state.modules.find(m => m.id === conn.to.moduleId); oscEntry.node.frequency.value = freq;
if (oscEntry?.node && oscMod?.type === 'oscillator') {
oscEntry.node.frequency.value = freq;
}
} }
} }
// Trigger connected envelopes // Trigger connected envelopes
for (const conn of state.connections) { for (const conn of getConnectionsFrom(moduleId, 'gate')) {
if (conn.from.moduleId === moduleId && conn.from.port === 'gate') { const envEntry = audioNodes[conn.to.moduleId];
const envEntry = audioNodes[conn.to.moduleId]; if (envEntry && envEntry.node instanceof Tone.Envelope) {
if (envEntry && envEntry.node instanceof Tone.Envelope) { if (gate) envEntry.node.triggerAttack();
if (gate) envEntry.node.triggerAttack(); else envEntry.node.triggerRelease();
else envEntry.node.triggerRelease();
}
} }
} }
} }
@@ -396,25 +492,20 @@ export function triggerKeyboard(moduleId, freq, gate) {
if (entry._freqSig) entry._freqSig.value = freq; if (entry._freqSig) entry._freqSig.value = freq;
if (entry._gateSig) entry._gateSig.value = gate ? 1 : 0; if (entry._gateSig) entry._gateSig.value = gate ? 1 : 0;
// Directly set connected oscillator frequencies (bypasses the modulation Gain) // Set connected oscillator frequencies directly
for (const conn of state.connections) { for (const conn of getConnectionsFrom(moduleId, 'freq')) {
if (conn.from.moduleId === moduleId && conn.from.port === 'freq') { const oscEntry = audioNodes[conn.to.moduleId];
const oscEntry = audioNodes[conn.to.moduleId]; if (oscEntry?.node?.frequency) {
const oscMod = state.modules.find(m => m.id === conn.to.moduleId); oscEntry.node.frequency.value = freq;
if (oscEntry?.node && oscMod?.type === 'oscillator') {
oscEntry.node.frequency.value = freq;
}
} }
} }
// Also trigger any connected envelopes // Trigger connected envelopes
for (const conn of state.connections) { for (const conn of getConnectionsFrom(moduleId, 'gate')) {
if (conn.from.moduleId === moduleId && conn.from.port === 'gate') { const envEntry = audioNodes[conn.to.moduleId];
const envEntry = audioNodes[conn.to.moduleId]; if (envEntry && envEntry.node instanceof Tone.Envelope) {
if (envEntry && envEntry.node instanceof Tone.Envelope) { if (gate) envEntry.node.triggerAttack();
if (gate) envEntry.node.triggerAttack(); else envEntry.node.triggerRelease();
else envEntry.node.triggerRelease();
}
} }
} }
} }
@@ -422,18 +513,21 @@ export function triggerKeyboard(moduleId, freq, gate) {
export async function startAudio() { export async function startAudio() {
await Tone.start(); await Tone.start();
state.isRunning = true; state.isRunning = true;
startMasterClock();
// Rebuild entire audio graph // Rebuild entire audio graph
rebuildGraph(); rebuildGraph();
} }
export function stopAudio() { export function stopAudio() {
// Stop and reset Transport so pianoroll/sequencer Parts don't get stranded stopMasterClock();
// Stop and reset Transport
try { try {
Tone.getTransport().stop(); Tone.getTransport().stop();
Tone.getTransport().cancel(); // Remove all scheduled events Tone.getTransport().cancel();
Tone.getTransport().position = 0; Tone.getTransport().position = 0;
} catch (e) { /* ignore if Transport not started */ } } catch (e) {}
// Destroy all nodes // Destroy all nodes
for (const id of Object.keys(audioNodes)) { for (const id of Object.keys(audioNodes)) {
@@ -458,6 +552,15 @@ export function rebuildGraph() {
connectWire(conn); 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). // Auto-trigger envelopes that have no gate connection (free-running mode).
// This allows noise/ambient patches to work without a keyboard/sequencer. // This allows noise/ambient patches to work without a keyboard/sequencer.
for (const mod of state.modules) { 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) { 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' }, 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' }, 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' }, 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: {}, 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 ==================== // ==================== OUTPUT ====================
defineModule('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 ==================== // ==================== SEQUENCER ====================
defineModule('sequencer', { defineModule('sequencer', {

View File

@@ -4,6 +4,7 @@
*/ */
import { playConnect, playDisconnect, playModuleAdd, playModuleDelete } from './uiSounds.js'; import { playConnect, playDisconnect, playModuleAdd, playModuleDelete } from './uiSounds.js';
import { getModuleDef } from './moduleRegistry.js'; import { getModuleDef } from './moduleRegistry.js';
import { invalidateConnectionCache } from './audioEngine.js';
let _listeners = new Set(); let _listeners = new Set();
let _nextModuleId = 1; let _nextModuleId = 1;
@@ -93,6 +94,7 @@ export function addConnection(fromModuleId, fromPort, toModuleId, toPort) {
const id = _nextConnectionId++; const id = _nextConnectionId++;
state.connections.push({ id, from: { moduleId: fromModuleId, port: fromPort }, to: { moduleId: toModuleId, port: toPort } }); state.connections.push({ id, from: { moduleId: fromModuleId, port: fromPort }, to: { moduleId: toModuleId, port: toPort } });
invalidateConnectionCache();
emit(); emit();
playConnect(); playConnect();
return id; return id;
@@ -100,6 +102,7 @@ export function addConnection(fromModuleId, fromPort, toModuleId, toPort) {
export function removeConnection(id, _silent = false) { export function removeConnection(id, _silent = false) {
state.connections = state.connections.filter(c => c.id !== id); state.connections = state.connections.filter(c => c.id !== id);
invalidateConnectionCache();
emit(); emit();
if (!_silent) playDisconnect(); if (!_silent) playDisconnect();
} }

View File

@@ -4,6 +4,9 @@ import { startAudio, stopAudio, connectWire, rebuildGraph } from '../engine/audi
import { getModuleDef } from '../engine/moduleRegistry.js'; import { getModuleDef } from '../engine/moduleRegistry.js';
import ModuleNode from '../components/ModuleNode.jsx'; import ModuleNode from '../components/ModuleNode.jsx';
import WireLayer from '../components/WireLayer.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 { playTarget, stopTarget, isTargetPlaying } from './targetAudio.js';
import LevelComplete from './LevelComplete.jsx'; import LevelComplete from './LevelComplete.jsx';
import { completeLevel, saveLevelPatch, getLevelPatch, clearLevelPatch, markHintUsed, wasHintUsed } from './gameState.js'; 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 [showHint, setShowHint] = useState(false);
const [result, setResult] = useState(null); const [result, setResult] = useState(null);
const [targetPlaying, setTargetPlaying] = useState(false); 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(() => { useEffect(() => {
const unsub = subscribe(() => { const unsub = subscribe(() => {
@@ -49,7 +59,10 @@ export default function PuzzleView({ level, levelIndex, worldLevels, onBack, onN
useEffect(() => { useEffect(() => {
loadLevel(); loadLevel();
// Center view on modules after level loads and DOM settles
const timer = setTimeout(() => handleCenterView(), 100);
return () => { return () => {
clearTimeout(timer);
stopAudio(); stopAudio();
stopTarget(); 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 }; state.panStart = { x: e.clientX - state.camX, y: e.clientY - state.camY };
e.preventDefault(); e.preventDefault();
} else if (e.button === 0 && !connectingRef.current) { } 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; state.selectedModuleId = null;
emit(); emit();
} }
}, []); }, [isMobile]);
const handlePointerMove = useCallback((e) => { const handlePointerMove = useCallback((e) => {
if (state.panning && state.panStart) { if (state.panning && state.panStart) {
@@ -335,7 +355,7 @@ export default function PuzzleView({ level, levelIndex, worldLevels, onBack, onN
<div className="gm-puzzle"> <div className="gm-puzzle">
{/* Top bar */} {/* Top bar */}
<div className="gm-puzzle-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"> <div className="gm-puzzle-title">
<span className="gm-puzzle-num">{levelIndex + 1}/{worldLevels.length}</span> <span className="gm-puzzle-num">{levelIndex + 1}/{worldLevels.length}</span>
<span className="gm-puzzle-name">{level.title}</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'}`} className={`gm-btn ${targetPlaying ? 'active' : 'target'}`}
onClick={handlePlayTarget} onClick={handlePlayTarget}
> >
{targetPlaying ? '⏹ Parar' : '🎯 Objetivo'} {targetPlaying ? '⏹' : '🎯'}{!isMobile && <span className="btn-label">{targetPlaying ? ' Parar' : ' Objetivo'}</span>}
</button> </button>
<button <button
className={`gm-btn ${state.isRunning ? 'active' : ''}`} className={`gm-btn ${state.isRunning ? 'active' : ''}`}
onClick={handleToggleAudio} onClick={handleToggleAudio}
> >
{state.isRunning ? '⏹ Parar' : ' Mi Sonido'} {state.isRunning ? '⏹' : '▶'}{!isMobile && <span className="btn-label">{state.isRunning ? ' Parar' : ' Mi Sonido'}</span>}
</button>
<button className="gm-btn clear" onClick={handleClearCanvas} title="Limpiar canvas">
🗑 Limpiar
</button> </button>
{!isMobile && (
<button className="gm-btn clear" onClick={handleClearCanvas} title="Limpiar canvas">
🗑 Limpiar
</button>
)}
<button className="gm-btn check" onClick={handleCheck}> <button className="gm-btn check" onClick={handleCheck}>
Comprobar {!isMobile && <span className="btn-label"> Comprobar</span>}
</button> </button>
{adminMode && ( {adminMode && (
<button className="gm-btn admin-solve" onClick={handleAutoSolve} title="Admin: resolver nivel"> <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>
<div className="gm-puzzle-content"> <div className="gm-puzzle-content">
{/* Left sidebar */} {/* Left sidebar (desktop only — hidden on mobile via CSS) */}
<div className="gm-puzzle-sidebar"> <div className="gm-puzzle-sidebar">
{/* Description — always visible */} {/* Description — always visible */}
<div className="gm-concept-panel"> <div className="gm-concept-panel">
@@ -502,6 +524,89 @@ export default function PuzzleView({ level, levelIndex, worldLevels, onBack, onN
</div> </div>
</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 */} {/* Level complete overlay */}
{result && result.stars >= 1 && ( {result && result.stars >= 1 && (
<LevelComplete <LevelComplete

View File

@@ -1,4 +1,6 @@
import React, { useState, useRef } from 'react'; 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_1 } from './levels/world1.js';
import { WORLD_2 } from './levels/world2.js'; import { WORLD_2 } from './levels/world2.js';
import { WORLD_3 } from './levels/world3.js'; import { WORLD_3 } from './levels/world3.js';
@@ -39,11 +41,18 @@ function isWorldUnlocked(world) {
return getTotalStars() >= world.unlockStars; 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 }) { export default function WorldMap({ onSelectLevel, onSandbox, onAdmin }) {
const totalStars = getTotalStars(); const totalStars = getTotalStars();
const maxStars = getMaxStars(); const maxStars = getMaxStars();
const [search, setSearch] = useState(''); const [search, setSearch] = useState('');
const searchRef = useRef(null); const searchRef = useRef(null);
const isMobile = useIsMobile();
const query = search.trim().toLowerCase(); 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> </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-family: 'Inter', 'SF Pro', -apple-system, system-ui, sans-serif;
font-size: 13px; font-size: 13px;
-webkit-font-smoothing: antialiased; -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 ===== */ /* ===== Layout ===== */
.app { display: flex; flex-direction: column; height: 100vh; } .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 .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; } .module-body { padding: 10px 12px; display: flex; flex-direction: column; gap: 8px; }
/* Ports */ /* 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.active { background: var(--yellow); color: var(--bg); border-color: var(--yellow); }
.admin-star-btn.zero { color: var(--red); } .admin-star-btn.zero { color: var(--red); }
.admin-star-btn.zero:hover { border-color: var(--red); 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 />); 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(() => {});
});
}