refactor: restructure to monorepo with npm workspaces (Phase 0)
Move frontend to packages/client/, server to packages/server/.
Root package.json uses npm workspaces to orchestrate both.
Structure:
reaktor/
packages/client/ (React + Vite + Tone.js frontend)
packages/server/ (static file server, future API)
dist/ (built output, shared)
docker-compose.yml (app + PostgreSQL for future backend)
- npm run dev → runs Vite dev server from client workspace
- npm run build → builds client, outputs to root dist/
- npm run start → runs server.js serving dist/
- Dockerfile updated for multi-stage monorepo build
- docker-compose.yml added with PostgreSQL service (ready for Phase 1)
- All imports and paths preserved, zero functionality change
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
18
packages/client/index.html
Normal file
18
packages/client/index.html
Normal file
@@ -0,0 +1,18 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
|
||||
<title>Reaktor — MontLab Modular Synth</title>
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<link rel="manifest" href="/manifest.json" />
|
||||
<meta name="theme-color" content="#00e5ff" />
|
||||
<link rel="apple-touch-icon" href="/icon-192.png" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.jsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
20
packages/client/package.json
Normal file
20
packages/client/package.json
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"name": "@reaktor/client",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"tone": "^14.8.49"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-react": "^4.2.0",
|
||||
"vite": "^5.4.0"
|
||||
}
|
||||
}
|
||||
8
packages/client/public/favicon.svg
Normal file
8
packages/client/public/favicon.svg
Normal file
@@ -0,0 +1,8 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
||||
<rect width="32" height="32" rx="4" fill="#0a0a14"/>
|
||||
<circle cx="10" cy="16" r="3" fill="#00e5ff" opacity="0.9"/>
|
||||
<circle cx="22" cy="10" r="3" fill="#ff6644" opacity="0.9"/>
|
||||
<circle cx="22" cy="22" r="3" fill="#44ff88" opacity="0.9"/>
|
||||
<line x1="13" y1="16" x2="19" y2="10" stroke="#00e5ff" stroke-width="1.5" opacity="0.6"/>
|
||||
<line x1="13" y1="16" x2="19" y2="22" stroke="#00e5ff" stroke-width="1.5" opacity="0.6"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 496 B |
BIN
packages/client/public/icon-192.png
Normal file
BIN
packages/client/public/icon-192.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.5 KiB |
BIN
packages/client/public/icon-512.png
Normal file
BIN
packages/client/public/icon-512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 11 KiB |
15
packages/client/public/manifest.json
Normal file
15
packages/client/public/manifest.json
Normal 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
packages/client/public/sw.js
Normal file
33
packages/client/public/sw.js
Normal 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;
|
||||
})
|
||||
);
|
||||
});
|
||||
439
packages/client/src/App.jsx
Normal file
439
packages/client/src/App.jsx
Normal file
@@ -0,0 +1,439 @@
|
||||
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { state, subscribe, addModule, emit, addConnection, updateModulePosition, deserialize } from './engine/state.js';
|
||||
import { startAudio, stopAudio, connectWire, rebuildGraph } from './engine/audioEngine.js';
|
||||
import { getModuleDef } from './engine/moduleRegistry.js';
|
||||
import { autoSave, autoLoad, exportPatch, importPatch } from './engine/presets.js';
|
||||
import { playEngineStart, playEngineStop } from './engine/uiSounds.js';
|
||||
import ModuleNode from './components/ModuleNode.jsx';
|
||||
import WireLayer from './components/WireLayer.jsx';
|
||||
import ModulePalette from './components/ModulePalette.jsx';
|
||||
import PresetModal from './components/PresetModal.jsx';
|
||||
import BottomSheet from './components/BottomSheet.jsx';
|
||||
import { CHIPTUNE_PRESET } from './presets/chiptune.js';
|
||||
import { useIsMobile } from './hooks/useIsMobile.js';
|
||||
import { usePinchZoom } from './hooks/usePinchZoom.js';
|
||||
import { getModulesByCategory } from './engine/moduleRegistry.js';
|
||||
|
||||
export default function App({ onSwitchToGame }) {
|
||||
const [, forceUpdate] = useState(0);
|
||||
const containerRef = useRef(null);
|
||||
const portPositions = useRef({});
|
||||
const [tempWire, setTempWire] = useState(null);
|
||||
const connectingRef = useRef(null);
|
||||
const [presetModal, setPresetModal] = useState(null);
|
||||
const importRef = useRef(null);
|
||||
const isMobile = useIsMobile();
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
|
||||
// Pinch-to-zoom on mobile
|
||||
const getZoom = useCallback(() => state.zoom, []);
|
||||
const setZoom = useCallback((z) => { state.zoom = z; emit(); }, []);
|
||||
usePinchZoom(containerRef, getZoom, setZoom);
|
||||
|
||||
// Subscribe to state changes
|
||||
useEffect(() => {
|
||||
const unsub = subscribe(() => forceUpdate(n => n + 1));
|
||||
return unsub;
|
||||
}, []);
|
||||
|
||||
// Auto-load on mount, or load chiptune demo if empty
|
||||
useEffect(() => {
|
||||
const loaded = autoLoad();
|
||||
if (!loaded || state.modules.length === 0) {
|
||||
// Load chiptune demo preset
|
||||
deserialize(CHIPTUNE_PRESET);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Auto-save interval
|
||||
useEffect(() => {
|
||||
const interval = setInterval(autoSave, 3000);
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
// Port position reporting
|
||||
const handlePortPosition = useCallback((moduleId, portName, direction, el) => {
|
||||
const key = `${moduleId}-${portName}-${direction}`;
|
||||
portPositions.current[key] = el;
|
||||
}, []);
|
||||
|
||||
// Start connecting a wire
|
||||
const handleStartConnect = useCallback((info) => {
|
||||
connectingRef.current = info;
|
||||
const containerRect = containerRef.current.getBoundingClientRect();
|
||||
setTempWire({
|
||||
portType: info.portType,
|
||||
startX: info.startX - containerRect.left,
|
||||
startY: info.startY - containerRect.top,
|
||||
endX: info.startX - containerRect.left,
|
||||
endY: info.startY - containerRect.top,
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Find port-dot element at pointer position (including nearby)
|
||||
// Robust port detection — searches all port-dots by bounding rect distance
|
||||
// instead of elementFromPoint (which gets blocked by SVG wire overlay)
|
||||
const findPortAtPoint = (clientX, clientY) => {
|
||||
const portDots = document.querySelectorAll('.port-dot[data-module-id]');
|
||||
let closest = null;
|
||||
let closestDist = 18;
|
||||
for (const dot of portDots) {
|
||||
const rect = dot.getBoundingClientRect();
|
||||
const cx = rect.left + rect.width / 2;
|
||||
const cy = rect.top + rect.height / 2;
|
||||
const dist = Math.sqrt((clientX - cx) ** 2 + (clientY - cy) ** 2);
|
||||
if (dist < closestDist) {
|
||||
closestDist = dist;
|
||||
closest = dot;
|
||||
}
|
||||
}
|
||||
return closest;
|
||||
};
|
||||
|
||||
// Canvas pointer events
|
||||
const handlePointerDown = useCallback((e) => {
|
||||
if (e.button === 1 || (e.button === 0 && e.altKey) || e.button === 2) {
|
||||
state.panning = true;
|
||||
state.panStart = { x: e.clientX - state.camX, y: e.clientY - state.camY };
|
||||
e.preventDefault();
|
||||
} else if (e.button === 0 && !connectingRef.current) {
|
||||
// On mobile (touch), single finger on empty canvas = pan
|
||||
if (isMobile && e.pointerType === 'touch') {
|
||||
state.panning = true;
|
||||
state.panStart = { x: e.clientX - state.camX, y: e.clientY - state.camY };
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
state.selectedModuleId = null;
|
||||
emit();
|
||||
}
|
||||
}, [isMobile]);
|
||||
|
||||
const handlePointerMove = useCallback((e) => {
|
||||
if (state.panning && state.panStart) {
|
||||
state.camX = e.clientX - state.panStart.x;
|
||||
state.camY = e.clientY - state.panStart.y;
|
||||
emit();
|
||||
return;
|
||||
}
|
||||
if (state.dragging) {
|
||||
const newX = e.clientX / state.zoom - state.dragging.offsetX;
|
||||
const newY = e.clientY / state.zoom - state.dragging.offsetY;
|
||||
updateModulePosition(state.dragging.moduleId, newX, newY);
|
||||
return;
|
||||
}
|
||||
if (connectingRef.current && containerRef.current) {
|
||||
const containerRect = containerRef.current.getBoundingClientRect();
|
||||
setTempWire(prev => prev ? {
|
||||
...prev,
|
||||
endX: e.clientX - containerRect.left,
|
||||
endY: e.clientY - containerRect.top,
|
||||
} : null);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handlePointerUp = useCallback((e) => {
|
||||
if (state.panning) {
|
||||
state.panning = false;
|
||||
state.panStart = null;
|
||||
}
|
||||
if (state.dragging) {
|
||||
state.dragging = null;
|
||||
emit();
|
||||
}
|
||||
|
||||
// End connecting
|
||||
if (connectingRef.current) {
|
||||
const portEl = findPortAtPoint(e.clientX, e.clientY);
|
||||
if (portEl) {
|
||||
finishConnection(portEl);
|
||||
}
|
||||
connectingRef.current = null;
|
||||
setTempWire(null);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const finishConnection = (portEl) => {
|
||||
const from = connectingRef.current;
|
||||
if (!from) return;
|
||||
|
||||
// Read data attributes directly — clean and reliable
|
||||
const targetModuleId = parseInt(portEl.dataset.moduleId);
|
||||
const targetPort = portEl.dataset.portName;
|
||||
const targetDirection = portEl.dataset.portDirection;
|
||||
|
||||
if (!targetModuleId || !targetPort || !targetDirection) return;
|
||||
if (targetModuleId === from.moduleId && targetPort === from.port) return;
|
||||
|
||||
// Determine from/to
|
||||
let fromMod, fromPort, toMod, toPort;
|
||||
if (from.direction === 'output' && targetDirection === 'input') {
|
||||
fromMod = from.moduleId; fromPort = from.port;
|
||||
toMod = targetModuleId; toPort = targetPort;
|
||||
} else if (from.direction === 'input' && targetDirection === 'output') {
|
||||
fromMod = targetModuleId; fromPort = targetPort;
|
||||
toMod = from.moduleId; toPort = from.port;
|
||||
} else {
|
||||
return; // same direction — invalid
|
||||
}
|
||||
|
||||
const connId = addConnection(fromMod, fromPort, toMod, toPort);
|
||||
if (connId && state.isRunning) {
|
||||
const conn = state.connections.find(c => c.id === connId);
|
||||
if (conn) connectWire(conn);
|
||||
}
|
||||
};
|
||||
|
||||
const handleWheel = useCallback((e) => {
|
||||
e.preventDefault();
|
||||
const delta = -e.deltaY * 0.001;
|
||||
state.zoom = Math.max(0.3, Math.min(3, state.zoom + delta));
|
||||
emit();
|
||||
}, []);
|
||||
|
||||
const handleContextMenu = useCallback((e) => e.preventDefault(), []);
|
||||
|
||||
// Zoom controls (Google Maps style)
|
||||
const handleZoomIn = useCallback(() => {
|
||||
state.zoom = Math.min(3, state.zoom * 1.25);
|
||||
emit();
|
||||
}, []);
|
||||
const handleZoomOut = useCallback(() => {
|
||||
state.zoom = Math.max(0.3, state.zoom / 1.25);
|
||||
emit();
|
||||
}, []);
|
||||
const handleZoomReset = useCallback(() => {
|
||||
state.zoom = 1;
|
||||
state.camX = 0;
|
||||
state.camY = 0;
|
||||
emit();
|
||||
}, []);
|
||||
|
||||
// Center view on all modules
|
||||
const handleCenterView = useCallback(() => {
|
||||
if (state.modules.length === 0) { state.camX = 0; state.camY = 0; emit(); return; }
|
||||
const container = containerRef.current;
|
||||
const cw = container?.clientWidth || 800;
|
||||
const ch = container?.clientHeight || 600;
|
||||
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
||||
for (const m of state.modules) {
|
||||
minX = Math.min(minX, m.x);
|
||||
minY = Math.min(minY, m.y);
|
||||
maxX = Math.max(maxX, m.x + 200);
|
||||
maxY = Math.max(maxY, m.y + 150);
|
||||
}
|
||||
const cx = (minX + maxX) / 2 * state.zoom;
|
||||
const cy = (minY + maxY) / 2 * state.zoom;
|
||||
state.camX = cw / 2 - cx;
|
||||
state.camY = ch / 2 - cy;
|
||||
emit();
|
||||
}, []);
|
||||
|
||||
const handleToggleAudio = async () => {
|
||||
if (state.isRunning) {
|
||||
stopAudio();
|
||||
playEngineStop();
|
||||
} else {
|
||||
await startAudio();
|
||||
playEngineStart();
|
||||
}
|
||||
emit();
|
||||
};
|
||||
|
||||
const handleAddModule = (type) => {
|
||||
const x = (-state.camX + 300) / state.zoom + Math.random() * 50;
|
||||
const y = (-state.camY + 200) / state.zoom + Math.random() * 50;
|
||||
addModule(type, x, y);
|
||||
if (state.isRunning) rebuildGraph();
|
||||
};
|
||||
|
||||
const handleImport = async (e) => {
|
||||
const file = e.target.files[0];
|
||||
if (!file) return;
|
||||
await importPatch(file);
|
||||
emit();
|
||||
e.target.value = '';
|
||||
};
|
||||
|
||||
const handleLoadDemo = () => {
|
||||
deserialize(CHIPTUNE_PRESET);
|
||||
if (state.isRunning) rebuildGraph();
|
||||
emit();
|
||||
};
|
||||
|
||||
const handleClearCanvas = () => {
|
||||
if (state.isRunning) stopAudio();
|
||||
deserialize({ modules: [], connections: [] });
|
||||
emit();
|
||||
};
|
||||
|
||||
// Flatten all modules for mobile grid
|
||||
const allModuleDefs = Object.values(getModulesByCategory()).flat();
|
||||
|
||||
return (
|
||||
<div className="app">
|
||||
{/* Toolbar */}
|
||||
<div className="toolbar">
|
||||
{onSwitchToGame && !isMobile && (
|
||||
<button className="toolbar-btn" onClick={onSwitchToGame} style={{ color: 'var(--yellow)' }}>
|
||||
🎮 Game
|
||||
</button>
|
||||
)}
|
||||
<span className="toolbar-title">Reaktor</span>
|
||||
{!isMobile && <div className="toolbar-sep" />}
|
||||
{!isMobile && (
|
||||
<button className={`toolbar-btn start-btn ${state.isRunning ? 'active' : ''}`} onClick={handleToggleAudio}>
|
||||
{state.isRunning ? '⏹ Stop' : '▶ Start'}
|
||||
</button>
|
||||
)}
|
||||
{!isMobile && <div className="toolbar-sep" />}
|
||||
{!isMobile && (
|
||||
<div className="toolbar-group">
|
||||
<button className="toolbar-btn save-btn" onClick={() => setPresetModal('save')}>💾 Save</button>
|
||||
<button className="toolbar-btn load-btn" onClick={() => setPresetModal('load')}>📂 Load</button>
|
||||
<button className="toolbar-btn export-btn" onClick={exportPatch}>📤 Export</button>
|
||||
<button className="toolbar-btn import-btn" onClick={() => importRef.current?.click()}>📥 Import</button>
|
||||
<input ref={importRef} type="file" accept=".json" style={{ display: 'none' }} onChange={handleImport} />
|
||||
</div>
|
||||
)}
|
||||
{!isMobile && <div className="toolbar-sep" />}
|
||||
{!isMobile && (
|
||||
<>
|
||||
<button className="toolbar-btn demo-btn" onClick={handleLoadDemo} style={{ color: 'var(--yellow)' }}>
|
||||
🎮 Chiptune Demo
|
||||
</button>
|
||||
<button className="toolbar-btn clear-btn" onClick={handleClearCanvas}>
|
||||
🗑 Limpiar
|
||||
</button>
|
||||
<div className="toolbar-sep" />
|
||||
</>
|
||||
)}
|
||||
<span className="toolbar-label" style={{ color: state.isRunning ? 'var(--green)' : 'var(--text2)', marginLeft: isMobile ? 'auto' : undefined }}>
|
||||
{state.isRunning ? '● LIVE' : '○ OFF'}
|
||||
</span>
|
||||
{!isMobile && (
|
||||
<span className="toolbar-label" style={{ marginLeft: 'auto' }}>
|
||||
{state.modules.length} modules · {state.connections.length} wires
|
||||
</span>
|
||||
)}
|
||||
{isMobile && (
|
||||
<button className="mobile-menu-btn" onClick={() => setMenuOpen(true)}>≡</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Mobile menu overlay */}
|
||||
{isMobile && menuOpen && (
|
||||
<div className="mobile-menu-overlay" onClick={() => setMenuOpen(false)}>
|
||||
<div className="mobile-menu-panel" onClick={e => e.stopPropagation()}>
|
||||
{onSwitchToGame && (
|
||||
<button className="toolbar-btn" onClick={() => { setMenuOpen(false); onSwitchToGame(); }} style={{ color: 'var(--yellow)' }}>
|
||||
🎮 Game
|
||||
</button>
|
||||
)}
|
||||
<button className="toolbar-btn" onClick={() => { setPresetModal('save'); setMenuOpen(false); }}>💾 Save</button>
|
||||
<button className="toolbar-btn" onClick={() => { setPresetModal('load'); setMenuOpen(false); }}>📂 Load</button>
|
||||
<button className="toolbar-btn" onClick={() => { exportPatch(); setMenuOpen(false); }}>📤 Export</button>
|
||||
<button className="toolbar-btn" onClick={() => { importRef.current?.click(); setMenuOpen(false); }}>📥 Import</button>
|
||||
<input ref={importRef} type="file" accept=".json" style={{ display: 'none' }} onChange={handleImport} />
|
||||
<button className="toolbar-btn" onClick={() => { handleLoadDemo(); setMenuOpen(false); }} style={{ color: 'var(--yellow)' }}>
|
||||
🎮 Chiptune Demo
|
||||
</button>
|
||||
<button className="toolbar-btn" onClick={() => { handleClearCanvas(); setMenuOpen(false); }}>🗑 Limpiar</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Main canvas area */}
|
||||
<div className="main-area">
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={`node-canvas ${state.panning ? 'grabbing' : ''} ${connectingRef.current ? 'connecting' : ''}`}
|
||||
onPointerDown={handlePointerDown}
|
||||
onPointerMove={handlePointerMove}
|
||||
onPointerUp={handlePointerUp}
|
||||
onWheel={handleWheel}
|
||||
onContextMenu={handleContextMenu}
|
||||
>
|
||||
{/* Grid background */}
|
||||
<svg style={{ position: 'absolute', inset: 0, width: '100%', height: '100%', zIndex: 0 }}>
|
||||
<defs>
|
||||
<pattern id="grid" width={20 * state.zoom} height={20 * state.zoom}
|
||||
patternUnits="userSpaceOnUse"
|
||||
x={state.camX % (20 * state.zoom)} y={state.camY % (20 * state.zoom)}>
|
||||
<circle cx={1} cy={1} r={0.8} fill="#1a1a30" />
|
||||
</pattern>
|
||||
</defs>
|
||||
<rect width="100%" height="100%" fill="url(#grid)" />
|
||||
</svg>
|
||||
|
||||
{/* Wire layer */}
|
||||
<WireLayer portPositions={portPositions} tempWire={tempWire} containerRef={containerRef} zoom={state.zoom} camX={state.camX} camY={state.camY} />
|
||||
|
||||
{/* Modules container */}
|
||||
<div style={{ position: 'absolute', left: state.camX, top: state.camY, zIndex: 2 }}>
|
||||
{state.modules.map(mod => (
|
||||
<ModuleNode
|
||||
key={mod.id}
|
||||
mod={mod}
|
||||
zoom={state.zoom}
|
||||
onStartConnect={handleStartConnect}
|
||||
onPortPosition={handlePortPosition}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Zoom controls */}
|
||||
<div className="zoom-controls">
|
||||
<button className="zoom-btn" onClick={handleZoomIn} title="Zoom in">+</button>
|
||||
<button className="zoom-btn zoom-label" onClick={handleZoomReset} title="Reset zoom">
|
||||
{(state.zoom * 100).toFixed(0)}%
|
||||
</button>
|
||||
<button className="zoom-btn" onClick={handleZoomOut} title="Zoom out">−</button>
|
||||
<button className="zoom-btn" onClick={handleCenterView} title="Centrar vista">⌂</button>
|
||||
</div>
|
||||
|
||||
{/* Desktop palette */}
|
||||
{!isMobile && <ModulePalette onAddModule={handleAddModule} />}
|
||||
</div>
|
||||
|
||||
{/* Mobile action bar */}
|
||||
{isMobile && (
|
||||
<div className="mobile-action-bar">
|
||||
<button
|
||||
className={`start-btn-mobile ${state.isRunning ? 'active' : ''}`}
|
||||
onClick={handleToggleAudio}
|
||||
>
|
||||
{state.isRunning ? '⏹ STOP' : '▶ START'}
|
||||
</button>
|
||||
<button className="action-icon-btn" onClick={() => setPresetModal('save')}>💾</button>
|
||||
<button className="action-icon-btn" onClick={exportPatch}>📤</button>
|
||||
<button className="action-icon-btn" onClick={handleClearCanvas}>🗑</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Mobile bottom sheet with modules */}
|
||||
{isMobile && (
|
||||
<BottomSheet>
|
||||
<div className="mobile-module-grid">
|
||||
{allModuleDefs.map(def => (
|
||||
<div key={def.type} className="mobile-module-tile" onClick={() => handleAddModule(def.type)}>
|
||||
<span className="tile-icon">{def.icon}</span>
|
||||
<span className="tile-name">{def.name}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</BottomSheet>
|
||||
)}
|
||||
|
||||
{/* Status bar (hidden on mobile via CSS) */}
|
||||
<div className="status-bar">
|
||||
<span className="status-accent">Reaktor — MontLab Modular Synth</span>
|
||||
<span>Zoom: {(state.zoom * 100).toFixed(0)}%</span>
|
||||
<span>RClick: pan · Wheel: zoom · Drag port: wire · Click wire: delete</span>
|
||||
</div>
|
||||
|
||||
{presetModal && <PresetModal mode={presetModal} onClose={() => setPresetModal(null)} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
52
packages/client/src/components/BottomSheet.jsx
Normal file
52
packages/client/src/components/BottomSheet.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
109
packages/client/src/components/DrumPadWidget.jsx
Normal file
109
packages/client/src/components/DrumPadWidget.jsx
Normal 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
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
181
packages/client/src/components/KeyboardWidget.jsx
Normal file
181
packages/client/src/components/KeyboardWidget.jsx
Normal file
@@ -0,0 +1,181 @@
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { triggerKeyboard } from '../engine/audioEngine.js';
|
||||
import { state } from '../engine/state.js';
|
||||
|
||||
const NOTE_NAMES = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B'];
|
||||
|
||||
const KEY_MAP = {
|
||||
'z': 0, 's': 1, 'x': 2, 'd': 3, 'c': 4, 'v': 5, 'g': 6,
|
||||
'b': 7, 'h': 8, 'n': 9, 'j': 10, 'm': 11, ',': 12,
|
||||
'q': 12, '2': 13, 'w': 14, '3': 15, 'e': 16, 'r': 17,
|
||||
'5': 18, 't': 19, '6': 20, 'y': 21, '7': 22, 'u': 23, 'i': 24,
|
||||
};
|
||||
|
||||
function midiToFreq(midi) {
|
||||
return 440 * Math.pow(2, (midi - 69) / 12);
|
||||
}
|
||||
|
||||
// Fullscreen piano — 1 octave, big comfortable keys like a real piano app
|
||||
function FullscreenPiano({ moduleId, initialOctave, onClose }) {
|
||||
const [oct, setOct] = useState(initialOctave);
|
||||
const [activeNotes, setActiveNotes] = useState(new Set());
|
||||
|
||||
const play = useCallback((semitone) => {
|
||||
const midi = (oct + 1) * 12 + semitone;
|
||||
triggerKeyboard(moduleId, midiToFreq(midi), true);
|
||||
setActiveNotes(prev => new Set(prev).add(semitone));
|
||||
}, [moduleId, oct]);
|
||||
|
||||
const stop = useCallback((semitone) => {
|
||||
setActiveNotes(prev => {
|
||||
const next = new Set(prev);
|
||||
next.delete(semitone);
|
||||
if (next.size === 0) triggerKeyboard(moduleId, 440, false);
|
||||
return next;
|
||||
});
|
||||
}, [moduleId]);
|
||||
|
||||
// 1 octave: 7 white keys, 5 black keys
|
||||
const whiteKeys = [
|
||||
{ note: 0, name: 'C' },
|
||||
{ note: 2, name: 'D' },
|
||||
{ note: 4, name: 'E' },
|
||||
{ note: 5, name: 'F' },
|
||||
{ note: 7, name: 'G' },
|
||||
{ note: 9, name: 'A' },
|
||||
{ note: 11, name: 'B' },
|
||||
];
|
||||
|
||||
// Black key positions relative to white key index (0-6)
|
||||
const blackKeys = [
|
||||
{ note: 1, name: 'C#', after: 0 },
|
||||
{ note: 3, name: 'D#', after: 1 },
|
||||
{ note: 6, name: 'F#', after: 3 },
|
||||
{ note: 8, name: 'G#', after: 4 },
|
||||
{ note: 10, name: 'A#', after: 5 },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="keyboard-fullscreen">
|
||||
<div className="keyboard-fs-header">
|
||||
<button className="keyboard-fs-close" onClick={onClose}>✕</button>
|
||||
<button className="keyboard-fs-oct-btn" onClick={() => setOct(o => Math.max(1, o - 1))}>◀</button>
|
||||
<span className="keyboard-fs-title">Octave {oct}</span>
|
||||
<button className="keyboard-fs-oct-btn" onClick={() => setOct(o => Math.min(8, o + 1))}>▶</button>
|
||||
<div style={{ width: 36 }} />
|
||||
</div>
|
||||
<div className="keyboard-fs-keys">
|
||||
{whiteKeys.map((k, i) => (
|
||||
<div
|
||||
key={k.note}
|
||||
className={`keyboard-fs-white ${activeNotes.has(k.note) ? 'pressed' : ''}`}
|
||||
onPointerDown={() => play(k.note)}
|
||||
onPointerUp={() => stop(k.note)}
|
||||
onPointerLeave={() => stop(k.note)}
|
||||
onPointerCancel={() => stop(k.note)}
|
||||
>
|
||||
<span className="keyboard-fs-note-label">{k.name}{oct}</span>
|
||||
</div>
|
||||
))}
|
||||
{blackKeys.map((k) => (
|
||||
<div
|
||||
key={k.note}
|
||||
className={`keyboard-fs-black ${activeNotes.has(k.note) ? 'pressed' : ''}`}
|
||||
style={{ left: `${(k.after + 0.65) * (100 / 7)}%`, width: `${(100 / 7) * 0.65}%` }}
|
||||
onPointerDown={() => play(k.note)}
|
||||
onPointerUp={() => stop(k.note)}
|
||||
onPointerLeave={() => stop(k.note)}
|
||||
onPointerCancel={() => stop(k.note)}
|
||||
>
|
||||
<span className="keyboard-fs-black-label">{k.name}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function KeyboardWidget({ moduleId, fullscreen, onCloseFullscreen }) {
|
||||
const mod = state.modules.find(m => m.id === moduleId);
|
||||
const octave = mod?.params?.octave ?? 4;
|
||||
const activeKeys = useRef(new Set());
|
||||
|
||||
const playNote = useCallback((semitone) => {
|
||||
const midi = (octave + 1) * 12 + semitone;
|
||||
const freq = midiToFreq(midi);
|
||||
triggerKeyboard(moduleId, freq, true);
|
||||
}, [moduleId, octave]);
|
||||
|
||||
const stopNote = useCallback(() => {
|
||||
triggerKeyboard(moduleId, 440, false);
|
||||
}, [moduleId]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleDown = (e) => {
|
||||
if (e.repeat) return;
|
||||
const key = e.key.toLowerCase();
|
||||
if (KEY_MAP[key] !== undefined && !activeKeys.current.has(key)) {
|
||||
activeKeys.current.add(key);
|
||||
playNote(KEY_MAP[key]);
|
||||
}
|
||||
};
|
||||
const handleUp = (e) => {
|
||||
const key = e.key.toLowerCase();
|
||||
if (KEY_MAP[key] !== undefined) {
|
||||
activeKeys.current.delete(key);
|
||||
if (activeKeys.current.size === 0) stopNote();
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', handleDown);
|
||||
window.addEventListener('keyup', handleUp);
|
||||
return () => {
|
||||
window.removeEventListener('keydown', handleDown);
|
||||
window.removeEventListener('keyup', handleUp);
|
||||
};
|
||||
}, [playNote, stopNote]);
|
||||
|
||||
// Mini keyboard (1 octave)
|
||||
const whites = [0, 2, 4, 5, 7, 9, 11];
|
||||
const blacks = [1, 3, -1, 6, 8, 10];
|
||||
|
||||
return (
|
||||
<>
|
||||
<div style={{ padding: '2px 0' }}>
|
||||
<svg viewBox="0 0 140 30" style={{ width: '100%', height: 30 }}>
|
||||
{whites.map((note, i) => (
|
||||
<rect key={`w${i}`} x={i * 20} y={0} width={19} height={28}
|
||||
rx={1} fill="#222" stroke="#444" strokeWidth={0.5}
|
||||
style={{ cursor: 'pointer' }}
|
||||
onPointerDown={() => playNote(note)}
|
||||
onPointerUp={stopNote}
|
||||
/>
|
||||
))}
|
||||
{blacks.filter(n => n >= 0).map((note, i) => {
|
||||
const pos = [1, 2, 4, 5, 6][i];
|
||||
return (
|
||||
<rect key={`b${i}`} x={pos * 20 - 6} y={0} width={12} height={18}
|
||||
rx={1} fill="#111" stroke="#333" strokeWidth={0.5}
|
||||
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>
|
||||
|
||||
{fullscreen && createPortal(
|
||||
<FullscreenPiano
|
||||
moduleId={moduleId}
|
||||
initialOctave={octave}
|
||||
onClose={onCloseFullscreen}
|
||||
/>,
|
||||
document.body
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
150
packages/client/src/components/Knob.jsx
Normal file
150
packages/client/src/components/Knob.jsx
Normal file
@@ -0,0 +1,150 @@
|
||||
import React, { useRef, useCallback, useState } from 'react';
|
||||
|
||||
const SIZE = 32;
|
||||
const RADIUS = 12;
|
||||
const STROKE = 3;
|
||||
const START_ANGLE = 225;
|
||||
const END_ANGLE = -45;
|
||||
const RANGE = 270; // degrees
|
||||
|
||||
function polarToCart(cx, cy, r, deg) {
|
||||
const rad = (deg - 90) * Math.PI / 180;
|
||||
return { x: cx + r * Math.cos(rad), y: cy + r * Math.sin(rad) };
|
||||
}
|
||||
|
||||
function describeArc(cx, cy, r, startDeg, endDeg) {
|
||||
const start = polarToCart(cx, cy, r, endDeg);
|
||||
const end = polarToCart(cx, cy, r, startDeg);
|
||||
const large = endDeg - startDeg <= 180 ? '0' : '1';
|
||||
return `M ${start.x} ${start.y} A ${r} ${r} 0 ${large} 0 ${end.x} ${end.y}`;
|
||||
}
|
||||
|
||||
export default function Knob({ value, min, max, onChange, color = 'var(--accent)', label, unit, formatValue, modulated = false, liveValue }) {
|
||||
const ref = useRef(null);
|
||||
const dragRef = useRef(null);
|
||||
const inputRef = useRef(null);
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [editText, setEditText] = useState('');
|
||||
|
||||
// Use liveValue for visual display when being modulated, base value for interaction
|
||||
const displayNum = liveValue !== undefined ? liveValue : value;
|
||||
const clampedDisplay = Math.max(min, Math.min(max, displayNum));
|
||||
const norm = Math.max(0, Math.min(1, (clampedDisplay - min) / (max - min)));
|
||||
const angleDeg = START_ANGLE - norm * RANGE;
|
||||
|
||||
const cx = SIZE / 2, cy = SIZE / 2;
|
||||
const trackPath = describeArc(cx, cy, RADIUS, END_ANGLE, START_ANGLE);
|
||||
const fillAngle = START_ANGLE - norm * RANGE;
|
||||
const fillPath = norm > 0.001 ? describeArc(cx, cy, RADIUS, fillAngle, START_ANGLE) : '';
|
||||
|
||||
const dotPos = polarToCart(cx, cy, RADIUS - 4, angleDeg);
|
||||
|
||||
// Also show base value indicator when modulated
|
||||
const baseNorm = Math.max(0, Math.min(1, (value - min) / (max - min)));
|
||||
const baseAngle = START_ANGLE - baseNorm * RANGE;
|
||||
const baseDotPos = polarToCart(cx, cy, RADIUS - 4, baseAngle);
|
||||
|
||||
const displayVal = formatValue ? formatValue(displayNum) :
|
||||
displayNum >= 1000 ? `${(displayNum / 1000).toFixed(1)}k` :
|
||||
displayNum >= 100 ? Math.round(displayNum) :
|
||||
displayNum >= 1 ? displayNum.toFixed(1) :
|
||||
displayNum.toFixed(3).replace(/0+$/, '').replace(/\.$/, '');
|
||||
|
||||
const handlePointerDown = useCallback((e) => {
|
||||
if (editing) return;
|
||||
e.preventDefault(); e.stopPropagation();
|
||||
dragRef.current = { startY: e.clientY, startValue: value };
|
||||
const handleMove = (me) => {
|
||||
const dy = dragRef.current.startY - me.clientY;
|
||||
const sensitivity = (max - min) / 200;
|
||||
let newVal = dragRef.current.startValue + dy * sensitivity;
|
||||
newVal = Math.max(min, Math.min(max, newVal));
|
||||
if (max - min > 10 && Number.isInteger(min) && Number.isInteger(max)) {
|
||||
newVal = Math.round(newVal);
|
||||
}
|
||||
onChange(newVal);
|
||||
};
|
||||
const handleUp = () => {
|
||||
window.removeEventListener('pointermove', handleMove);
|
||||
window.removeEventListener('pointerup', handleUp);
|
||||
dragRef.current = null;
|
||||
};
|
||||
window.addEventListener('pointermove', handleMove);
|
||||
window.addEventListener('pointerup', handleUp);
|
||||
}, [value, min, max, onChange, editing]);
|
||||
|
||||
const handleWheel = useCallback((e) => {
|
||||
if (editing) return;
|
||||
e.preventDefault(); e.stopPropagation();
|
||||
const step = (max - min) / 100;
|
||||
const newVal = Math.max(min, Math.min(max, value - Math.sign(e.deltaY) * step));
|
||||
onChange(newVal);
|
||||
}, [value, min, max, onChange, editing]);
|
||||
|
||||
// Double-click: open inline text input
|
||||
const handleDoubleClick = useCallback((e) => {
|
||||
e.preventDefault(); e.stopPropagation();
|
||||
setEditText(String(typeof displayVal === 'number' ? displayVal : value));
|
||||
setEditing(true);
|
||||
// Focus input after render
|
||||
setTimeout(() => inputRef.current?.focus(), 0);
|
||||
}, [value, displayVal]);
|
||||
|
||||
const commitEdit = useCallback(() => {
|
||||
const parsed = parseFloat(editText);
|
||||
if (!isNaN(parsed)) {
|
||||
const clamped = Math.max(min, Math.min(max, parsed));
|
||||
onChange(clamped);
|
||||
}
|
||||
setEditing(false);
|
||||
}, [editText, min, max, onChange]);
|
||||
|
||||
const handleInputKeyDown = useCallback((e) => {
|
||||
e.stopPropagation();
|
||||
if (e.key === 'Enter') {
|
||||
commitEdit();
|
||||
} else if (e.key === 'Escape') {
|
||||
setEditing(false);
|
||||
}
|
||||
}, [commitEdit]);
|
||||
|
||||
const handleInputBlur = useCallback(() => {
|
||||
commitEdit();
|
||||
}, [commitEdit]);
|
||||
|
||||
if (editing) {
|
||||
return (
|
||||
<div className="knob-container knob-editing" onWheel={(e) => e.stopPropagation()}>
|
||||
<input
|
||||
ref={inputRef}
|
||||
className="knob-input"
|
||||
type="text"
|
||||
value={editText}
|
||||
onChange={(e) => setEditText(e.target.value)}
|
||||
onKeyDown={handleInputKeyDown}
|
||||
onBlur={handleInputBlur}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`knob-container ${modulated ? 'knob-modulated' : ''}`} onWheel={handleWheel} onDoubleClick={handleDoubleClick}>
|
||||
<svg className="knob-svg" viewBox={`0 0 ${SIZE} ${SIZE}`}
|
||||
onPointerDown={handlePointerDown} ref={ref}>
|
||||
{/* Modulation glow ring */}
|
||||
{modulated && (
|
||||
<circle className="knob-mod-ring" cx={cx} cy={cy} r={RADIUS + 1} style={{ stroke: color }} />
|
||||
)}
|
||||
<path className="knob-track" d={trackPath} />
|
||||
{fillPath && <path className="knob-fill" d={fillPath} style={{ stroke: color }} />}
|
||||
{/* Ghost dot at base value position when modulated */}
|
||||
{liveValue !== undefined && (
|
||||
<circle className="knob-base-dot" cx={baseDotPos.x} cy={baseDotPos.y} r={1.5} />
|
||||
)}
|
||||
<circle className="knob-dot" cx={dotPos.x} cy={dotPos.y} r={2} />
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
16
packages/client/src/components/MobileTabBar.jsx
Normal file
16
packages/client/src/components/MobileTabBar.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
299
packages/client/src/components/ModuleNode.jsx
Normal file
299
packages/client/src/components/ModuleNode.jsx
Normal file
@@ -0,0 +1,299 @@
|
||||
import React, { useCallback, useState, useEffect, useRef } from 'react';
|
||||
import { getModuleDef } from '../engine/moduleRegistry.js';
|
||||
import { state, removeModule, updateModuleParam, isPortConnected, emit } from '../engine/state.js';
|
||||
import { updateParam, getAudioNode } from '../engine/audioEngine.js';
|
||||
import Knob from './Knob.jsx';
|
||||
import ScopeDisplay from './ScopeDisplay.jsx';
|
||||
import KeyboardWidget from './KeyboardWidget.jsx';
|
||||
import DrumPadWidget from './DrumPadWidget.jsx';
|
||||
import SequencerWidget from './SequencerWidget.jsx';
|
||||
import PianoRollWidget from './PianoRollWidget.jsx';
|
||||
|
||||
// Dynamic module widths for sequencer/pianoroll based on step/bar count
|
||||
function getModuleWidth(mod, type) {
|
||||
if (type === 'sequencer') {
|
||||
const numSteps = parseInt(mod?.params?.steps || '16');
|
||||
return Math.max(200, numSteps * 18 + 20); // CELL_W=18 + padding
|
||||
}
|
||||
if (type === 'pianoroll') {
|
||||
const bars = parseInt(mod?.params?.bars || '4');
|
||||
const totalBeats = bars * 4;
|
||||
return 24 + totalBeats * 30 + 20; // KEY_W + beats*BEAT_PX + padding
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Map input port names → the param name they modulate (for visual feedback)
|
||||
const PORT_TO_PARAM = {
|
||||
filter: { cutoff: 'frequency' },
|
||||
oscillator: { freq: 'frequency', detune: 'detune' },
|
||||
vca: { cv: 'gain' },
|
||||
};
|
||||
|
||||
// Compute a simulated LFO waveform value at time t (seconds)
|
||||
function simulateLFO(waveform, phase) {
|
||||
switch (waveform) {
|
||||
case 'sine': return Math.sin(2 * Math.PI * phase);
|
||||
case 'triangle': return 1 - 4 * Math.abs((phase % 1) - 0.5);
|
||||
case 'sawtooth': return 2 * (phase % 1) - 1;
|
||||
case 'square': return (phase % 1) < 0.5 ? 1 : -1;
|
||||
default: return Math.sin(2 * Math.PI * phase);
|
||||
}
|
||||
}
|
||||
|
||||
export default function ModuleNode({ mod, zoom, onStartConnect, onPortPosition }) {
|
||||
const def = getModuleDef(mod.type);
|
||||
if (!def) return null;
|
||||
|
||||
const isSelected = state.selectedModuleId === mod.id;
|
||||
const [fullscreen, setFullscreen] = useState(false);
|
||||
|
||||
// Merge default params
|
||||
const params = { ...Object.fromEntries(Object.entries(def.params).map(([k, v]) => [k, v.default])), ...mod.params };
|
||||
|
||||
// Find which params are being modulated (have an incoming connection on their corresponding port)
|
||||
const modulatedParams = new Set();
|
||||
const portMap = PORT_TO_PARAM[mod.type] || {};
|
||||
for (const conn of state.connections) {
|
||||
if (conn.to.moduleId === mod.id && portMap[conn.to.port]) {
|
||||
modulatedParams.add(portMap[conn.to.port]);
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== Live modulation visualization (LFO + Envelope + any CV) ====================
|
||||
const [liveValues, setLiveValues] = useState({});
|
||||
const rafRef = useRef(null);
|
||||
const startTimeRef = useRef(performance.now() / 1000);
|
||||
|
||||
useEffect(() => {
|
||||
if (modulatedParams.size === 0) {
|
||||
setLiveValues({});
|
||||
return;
|
||||
}
|
||||
|
||||
let frameCount = 0;
|
||||
const tick = () => {
|
||||
frameCount++;
|
||||
rafRef.current = requestAnimationFrame(tick);
|
||||
if (frameCount % 4 !== 0) return;
|
||||
|
||||
const t = performance.now() / 1000 - startTimeRef.current;
|
||||
const newValues = {};
|
||||
|
||||
for (const conn of state.connections) {
|
||||
if (conn.to.moduleId !== mod.id) continue;
|
||||
const paramName = portMap[conn.to.port];
|
||||
if (!paramName) continue;
|
||||
|
||||
const srcMod = state.modules.find(m => m.id === conn.from.moduleId);
|
||||
if (!srcMod) continue;
|
||||
|
||||
if (srcMod.type === 'lfo') {
|
||||
// LFO: simulate waveform for smooth visual
|
||||
const lfoDef = getModuleDef('lfo');
|
||||
const lfoP = { ...Object.fromEntries(Object.entries(lfoDef.params).map(([k, v]) => [k, v.default])), ...srcMod.params };
|
||||
const freq = lfoP.frequency;
|
||||
const amp = lfoP.amplitude;
|
||||
const waveform = lfoP.waveform;
|
||||
const phase = (t * freq) % 1;
|
||||
const lfoVal = simulateLFO(waveform, phase) * amp;
|
||||
|
||||
const baseValue = params[paramName];
|
||||
let scale;
|
||||
if (mod.type === 'oscillator' && paramName === 'frequency') scale = baseValue * 0.5;
|
||||
else if (mod.type === 'filter' && paramName === 'frequency') scale = baseValue;
|
||||
else if (mod.type === 'vca' && paramName === 'gain') scale = 1;
|
||||
else scale = baseValue || 1;
|
||||
|
||||
newValues[paramName] = baseValue + lfoVal * scale;
|
||||
} else if (srcMod.type === 'envelope') {
|
||||
// Envelope: read the actual audio node gain value for real-time display
|
||||
const audioEntry = getAudioNode(mod.id);
|
||||
if (audioEntry?.node?.gain) {
|
||||
const currentGain = audioEntry.node.gain.value;
|
||||
newValues[paramName] = currentGain;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setLiveValues(newValues);
|
||||
};
|
||||
|
||||
rafRef.current = requestAnimationFrame(tick);
|
||||
return () => {
|
||||
if (rafRef.current) cancelAnimationFrame(rafRef.current);
|
||||
};
|
||||
}, [mod.id, mod.type, modulatedParams.size]);
|
||||
|
||||
const handleParamChange = useCallback((name, value) => {
|
||||
updateModuleParam(mod.id, name, value);
|
||||
updateParam(mod.id, name, value);
|
||||
}, [mod.id]);
|
||||
|
||||
const handleHeaderDown = useCallback((e) => {
|
||||
if (e.button !== 0) return;
|
||||
e.stopPropagation();
|
||||
state.selectedModuleId = mod.id;
|
||||
state.dragging = {
|
||||
moduleId: mod.id,
|
||||
offsetX: e.clientX / zoom - mod.x,
|
||||
offsetY: e.clientY / zoom - mod.y,
|
||||
};
|
||||
emit();
|
||||
}, [mod, zoom]);
|
||||
|
||||
const handleDelete = useCallback((e) => {
|
||||
e.stopPropagation();
|
||||
removeModule(mod.id);
|
||||
}, [mod.id]);
|
||||
|
||||
const handlePortMouseDown = useCallback((e, portName, direction) => {
|
||||
e.stopPropagation(); e.preventDefault();
|
||||
const portDef = direction === 'output'
|
||||
? def.outputs.find(p => p.name === portName)
|
||||
: def.inputs.find(p => p.name === portName);
|
||||
if (!portDef) return;
|
||||
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
onStartConnect({
|
||||
moduleId: mod.id,
|
||||
port: portName,
|
||||
portType: portDef.type,
|
||||
direction,
|
||||
startX: rect.left + rect.width / 2,
|
||||
startY: rect.top + rect.height / 2,
|
||||
});
|
||||
}, [mod.id, def, onStartConnect]);
|
||||
|
||||
// Report port positions for wire rendering
|
||||
const portRef = useCallback((el, portName, direction) => {
|
||||
if (el) {
|
||||
onPortPosition(mod.id, portName, direction, el);
|
||||
}
|
||||
}, [mod.id, onPortPosition]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`module ${isSelected ? 'selected' : ''}`}
|
||||
style={{
|
||||
left: mod.x * zoom, top: mod.y * zoom,
|
||||
transform: `scale(${zoom})`, transformOrigin: 'top left',
|
||||
...(mod.type === 'pianoroll' ? { width: getModuleWidth(mod, 'pianoroll') } : mod.type === 'sequencer' ? { width: getModuleWidth(mod, 'sequencer') } : {}),
|
||||
}}
|
||||
data-module-id={mod.id}
|
||||
onPointerDown={(e) => {
|
||||
// Don't deselect when clicking inside a module
|
||||
e.stopPropagation();
|
||||
state.selectedModuleId = mod.id; emit();
|
||||
}}
|
||||
>
|
||||
<div className="module-header" onPointerDown={handleHeaderDown}>
|
||||
<span className="type-icon">{def.icon}</span>
|
||||
<span className="type-name">{def.name}</span>
|
||||
{(mod.type === 'keyboard' || mod.type === 'drumpad') && (
|
||||
<button
|
||||
className="expand-btn"
|
||||
onClick={(e) => { e.stopPropagation(); setFullscreen(true); }}
|
||||
title="Pantalla completa"
|
||||
>⤢</button>
|
||||
)}
|
||||
<button className="close-btn" onClick={handleDelete}>✕</button>
|
||||
</div>
|
||||
|
||||
<div className="module-body">
|
||||
{/* Input ports */}
|
||||
{def.inputs.map(port => (
|
||||
<div key={port.name} className="port-row input">
|
||||
<div
|
||||
className={`port-dot ${port.type} ${isPortConnected(mod.id, port.name, 'input') ? 'connected' : ''}`}
|
||||
ref={el => portRef(el, port.name, 'input')}
|
||||
data-module-id={mod.id}
|
||||
data-port-name={port.name}
|
||||
data-port-direction="input"
|
||||
data-port-type={port.type}
|
||||
onPointerDown={e => handlePortMouseDown(e, port.name, 'input')}
|
||||
/>
|
||||
<span className="port-label">{port.label}</span>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Parameters */}
|
||||
{Object.entries(def.params).map(([name, paramDef]) => {
|
||||
if (paramDef.type === 'knob') {
|
||||
const color = paramDef.unit === 'Hz' ? 'var(--accent)' :
|
||||
paramDef.unit === 'dB' ? 'var(--green)' :
|
||||
paramDef.unit === 's' ? 'var(--purple)' : 'var(--accent)';
|
||||
return (
|
||||
<div key={name} className="param-row">
|
||||
<span className="param-label">{paramDef.label}</span>
|
||||
<Knob
|
||||
value={params[name]}
|
||||
min={paramDef.min}
|
||||
max={paramDef.max}
|
||||
onChange={v => handleParamChange(name, v)}
|
||||
color={color}
|
||||
modulated={modulatedParams.has(name)}
|
||||
liveValue={liveValues[name]}
|
||||
/>
|
||||
<span className={`param-value ${liveValues[name] !== undefined ? 'param-value-live' : ''}`}>
|
||||
{(() => {
|
||||
const v = liveValues[name] !== undefined ? liveValues[name] : params[name];
|
||||
const s = v >= 1000 ? `${(v / 1000).toFixed(1)}k` :
|
||||
v >= 100 ? Math.round(v) :
|
||||
v >= 1 ? Number(v).toFixed(1) :
|
||||
Number(v).toFixed(3).replace(/0+$/, '').replace(/\.$/, '');
|
||||
return s;
|
||||
})()}
|
||||
{paramDef.unit ? ` ${paramDef.unit}` : ''}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (paramDef.type === 'select') {
|
||||
return (
|
||||
<div key={name} className="param-row">
|
||||
<span className="param-label">{paramDef.label}</span>
|
||||
<select className="param-select" value={params[name]}
|
||||
onChange={e => handleParamChange(name, e.target.value)}>
|
||||
{paramDef.options.map(opt => <option key={opt} value={opt}>{opt}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
})}
|
||||
|
||||
{/* Scope display */}
|
||||
{mod.type === 'scope' && <ScopeDisplay moduleId={mod.id} />}
|
||||
|
||||
{/* Keyboard widget */}
|
||||
{mod.type === 'keyboard' && <KeyboardWidget moduleId={mod.id} fullscreen={fullscreen} onCloseFullscreen={() => setFullscreen(false)} />}
|
||||
|
||||
{/* Drum Pad widget */}
|
||||
{mod.type === 'drumpad' && <DrumPadWidget moduleId={mod.id} fullscreen={fullscreen} onCloseFullscreen={() => setFullscreen(false)} />}
|
||||
|
||||
{/* Sequencer widget */}
|
||||
{mod.type === 'sequencer' && <SequencerWidget moduleId={mod.id} />}
|
||||
|
||||
{/* Piano Roll widget */}
|
||||
{mod.type === 'pianoroll' && <PianoRollWidget moduleId={mod.id} />}
|
||||
|
||||
{/* Output ports */}
|
||||
{def.outputs.map(port => (
|
||||
<div key={port.name} className="port-row output">
|
||||
<div
|
||||
className={`port-dot ${port.type} ${isPortConnected(mod.id, port.name, 'output') ? 'connected' : ''}`}
|
||||
ref={el => portRef(el, port.name, 'output')}
|
||||
data-module-id={mod.id}
|
||||
data-port-name={port.name}
|
||||
data-port-direction="output"
|
||||
data-port-type={port.type}
|
||||
onPointerDown={e => handlePortMouseDown(e, port.name, 'output')}
|
||||
/>
|
||||
<span className="port-label">{port.label}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
24
packages/client/src/components/ModulePalette.jsx
Normal file
24
packages/client/src/components/ModulePalette.jsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import React from 'react';
|
||||
import { getModulesByCategory } from '../engine/moduleRegistry.js';
|
||||
|
||||
export default function ModulePalette({ onAddModule }) {
|
||||
const categories = getModulesByCategory();
|
||||
|
||||
return (
|
||||
<div className="palette">
|
||||
<div className="palette-title">Modules</div>
|
||||
{Object.entries(categories).map(([cat, modules]) => (
|
||||
<React.Fragment key={cat}>
|
||||
<div className="palette-title" style={{ marginTop: 6, color: 'var(--text2)', fontSize: 8 }}>{cat}</div>
|
||||
{modules.map(def => (
|
||||
<div key={def.type} className="palette-item"
|
||||
onClick={() => onAddModule(def.type)}>
|
||||
<span className="p-icon">{def.icon}</span>
|
||||
<span className="p-name">{def.name}</span>
|
||||
</div>
|
||||
))}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
457
packages/client/src/components/PianoRollWidget.jsx
Normal file
457
packages/client/src/components/PianoRollWidget.jsx
Normal file
@@ -0,0 +1,457 @@
|
||||
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import * as Tone from 'tone';
|
||||
import { state, updateModuleParam, emit } from '../engine/state.js';
|
||||
import { setSequencerSignals, subscribeTick, unsubscribeTick, MASTER_TICK_RATE } from '../engine/audioEngine.js';
|
||||
import { parseMidi } from '../utils/midiParser.js';
|
||||
|
||||
const NOTE_NAMES = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B'];
|
||||
const BLACK_KEYS = [1, 3, 6, 8, 10];
|
||||
|
||||
function midiToFreq(midi) { return 440 * Math.pow(2, (midi - 69) / 12); }
|
||||
function isBlack(midi) { return BLACK_KEYS.includes(midi % 12); }
|
||||
|
||||
// Super Mario Bros - Overworld Theme (NES, 1985)
|
||||
// BPM ~200, swing 8ths feel. Each beat = 1 quarter note.
|
||||
// s = eighth note unit (0.5 beats)
|
||||
const s = 0.5;
|
||||
const MARIO_MELODY = [
|
||||
// Bar 1: E5 E5 . E5 . C5 E5 .
|
||||
{ note: 76, start: 0, duration: s }, // E5
|
||||
{ note: 76, start: 1*s, duration: s }, // E5
|
||||
// rest
|
||||
{ note: 76, start: 3*s, duration: s }, // E5
|
||||
// rest
|
||||
{ note: 72, start: 5*s, duration: s }, // C5
|
||||
{ note: 76, start: 6*s, duration: 2*s }, // E5
|
||||
|
||||
// Bar 2: G5 . . . G4 . . .
|
||||
{ note: 79, start: 8*s, duration: 2*s }, // G5
|
||||
{ note: 67, start: 12*s, duration: 2*s }, // G4
|
||||
|
||||
// Bar 3: C5 . . G4 . . E4 . .
|
||||
{ note: 72, start: 16*s, duration: s }, // C5
|
||||
{ note: 67, start: 18*s, duration: s }, // G4
|
||||
{ note: 64, start: 20*s, duration: s }, // E4
|
||||
|
||||
// Bar 4: . A4 . B4 . Bb4 A4 .
|
||||
{ note: 69, start: 22*s, duration: s }, // A4
|
||||
{ note: 71, start: 24*s, duration: s }, // B4
|
||||
{ note: 70, start: 25*s, duration: s }, // Bb4
|
||||
{ note: 69, start: 26*s, duration: s }, // A4
|
||||
|
||||
// Bar 5: G4 E5 G5 A5 . F5 G5 .
|
||||
{ note: 67, start: 28*s, duration: 0.75 }, // G4 (triplet feel)
|
||||
{ note: 76, start: 30*s, duration: 0.75 }, // E5
|
||||
{ note: 79, start: 32*s, duration: s }, // G5
|
||||
{ note: 81, start: 33*s, duration: s }, // A5
|
||||
{ note: 77, start: 35*s, duration: s }, // F5
|
||||
{ note: 79, start: 36*s, duration: s }, // G5
|
||||
|
||||
// Bar 6: . E5 . C5 D5 B4 . .
|
||||
{ note: 76, start: 38*s, duration: s }, // E5
|
||||
{ note: 72, start: 40*s, duration: s }, // C5
|
||||
{ note: 74, start: 41*s, duration: s }, // D5
|
||||
{ note: 71, start: 42*s, duration: s }, // B4
|
||||
|
||||
// Bar 7: . . C5 . . G4 . . E4
|
||||
{ note: 72, start: 44*s, duration: s }, // C5
|
||||
{ note: 67, start: 46*s, duration: s }, // G4
|
||||
{ note: 64, start: 48*s, duration: s }, // E4
|
||||
|
||||
// Bar 8: . A4 . B4 . Bb4 A4 .
|
||||
{ note: 69, start: 50*s, duration: s }, // A4
|
||||
{ note: 71, start: 52*s, duration: s }, // B4
|
||||
{ note: 70, start: 53*s, duration: s }, // Bb4
|
||||
{ note: 69, start: 54*s, duration: s }, // A4
|
||||
|
||||
// Bar 9: G4 E5 G5 A5 . F5 G5 .
|
||||
{ note: 67, start: 56*s, duration: 0.75 }, // G4
|
||||
{ note: 76, start: 58*s, duration: 0.75 }, // E5
|
||||
{ note: 79, start: 60*s, duration: s }, // G5
|
||||
{ note: 81, start: 61*s, duration: s }, // A5
|
||||
{ note: 77, start: 63*s, duration: s }, // F5
|
||||
{ note: 79, start: 64*s, duration: s }, // G5
|
||||
|
||||
// Bar 10: . E5 . C5 D5 B4
|
||||
{ note: 76, start: 66*s, duration: s }, // E5
|
||||
{ note: 72, start: 68*s, duration: s }, // C5
|
||||
{ note: 74, start: 69*s, duration: s }, // D5
|
||||
{ note: 71, start: 70*s, duration: 2*s }, // B4
|
||||
];
|
||||
|
||||
const BEAT_PX = 30; // pixels per beat — constant density regardless of bar count
|
||||
const ROLL_H = 200;
|
||||
const KEY_W = 24;
|
||||
const MIN_NOTE = 48; // C3
|
||||
const MAX_NOTE = 84; // C6
|
||||
const NOTE_RANGE = MAX_NOTE - MIN_NOTE;
|
||||
const ROW_H = ROLL_H / NOTE_RANGE;
|
||||
|
||||
export default function PianoRollWidget({ moduleId }) {
|
||||
const mod = state.modules.find(m => m.id === moduleId);
|
||||
const canvasRef = useRef(null);
|
||||
const [tool, setTool] = useState('draw'); // 'draw' | 'erase'
|
||||
const drawingRef = useRef(null);
|
||||
const rafRef = useRef(null);
|
||||
const playPosRef = useRef(-1);
|
||||
const midiInputRef = useRef(null);
|
||||
|
||||
const bpm = mod?.params?.bpm ?? 140;
|
||||
const bars = parseInt(mod?.params?.bars || '4');
|
||||
const loop = mod?.params?.loop !== 'off';
|
||||
const totalBeats = bars * 4;
|
||||
|
||||
// Init notes
|
||||
if (!mod?.params?._notes) {
|
||||
if (mod) mod.params._notes = [...MARIO_MELODY];
|
||||
}
|
||||
const notes = mod?.params?._notes || MARIO_MELODY;
|
||||
const notesRef = useRef(notes);
|
||||
notesRef.current = notes;
|
||||
|
||||
const rollW = KEY_W + totalBeats * BEAT_PX;
|
||||
const beatW = BEAT_PX;
|
||||
|
||||
// Draw the piano roll
|
||||
const draw = useCallback(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
const ctx = canvas.getContext('2d');
|
||||
const w = canvas.width;
|
||||
const h = canvas.height;
|
||||
|
||||
ctx.fillStyle = '#06060e';
|
||||
ctx.fillRect(0, 0, w, h);
|
||||
|
||||
// Piano keys
|
||||
for (let i = 0; i < NOTE_RANGE; i++) {
|
||||
const midi = MAX_NOTE - 1 - i;
|
||||
const y = i * ROW_H;
|
||||
const black = isBlack(midi);
|
||||
|
||||
// Row background
|
||||
ctx.fillStyle = black ? '#0a0a16' : '#0e0e1a';
|
||||
ctx.fillRect(KEY_W, y, w - KEY_W, ROW_H);
|
||||
|
||||
// Grid lines
|
||||
ctx.strokeStyle = '#151525';
|
||||
ctx.lineWidth = 0.5;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(KEY_W, y); ctx.lineTo(w, y);
|
||||
ctx.stroke();
|
||||
|
||||
// Key label
|
||||
ctx.fillStyle = black ? '#1a1a30' : '#222240';
|
||||
ctx.fillRect(0, y, KEY_W, ROW_H);
|
||||
ctx.strokeStyle = '#0a0a14';
|
||||
ctx.strokeRect(0, y, KEY_W, ROW_H);
|
||||
|
||||
if (midi % 12 === 0) { // C notes
|
||||
ctx.fillStyle = '#4466aa';
|
||||
ctx.font = '7px monospace';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.fillText(`C${Math.floor(midi / 12) - 1}`, KEY_W / 2, y + ROW_H / 2 + 2.5);
|
||||
}
|
||||
}
|
||||
|
||||
// Beat grid lines
|
||||
for (let b = 0; b <= totalBeats; b++) {
|
||||
const x = KEY_W + b * beatW;
|
||||
ctx.strokeStyle = b % 4 === 0 ? '#2a2a50' : b % 1 === 0 ? '#151525' : '#101020';
|
||||
ctx.lineWidth = b % 4 === 0 ? 1 : 0.5;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x, 0); ctx.lineTo(x, h);
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
// Draw notes
|
||||
const currentNotes = notesRef.current;
|
||||
for (const n of currentNotes) {
|
||||
if (n.note < MIN_NOTE || n.note >= MAX_NOTE) continue;
|
||||
const row = MAX_NOTE - 1 - n.note;
|
||||
const x = KEY_W + n.start * beatW;
|
||||
const nw = n.duration * beatW;
|
||||
const y = row * ROW_H;
|
||||
|
||||
// Note body
|
||||
const gradient = ctx.createLinearGradient(x, y, x, y + ROW_H);
|
||||
gradient.addColorStop(0, '#00ccff');
|
||||
gradient.addColorStop(1, '#0066aa');
|
||||
ctx.fillStyle = gradient;
|
||||
ctx.fillRect(x + 0.5, y + 0.5, Math.max(nw - 1, 2), ROW_H - 1);
|
||||
|
||||
// Note border
|
||||
ctx.strokeStyle = '#00e5ff';
|
||||
ctx.lineWidth = 0.5;
|
||||
ctx.strokeRect(x + 0.5, y + 0.5, Math.max(nw - 1, 2), ROW_H - 1);
|
||||
|
||||
// Note label for wider notes
|
||||
if (nw > 15) {
|
||||
ctx.fillStyle = '#fff';
|
||||
ctx.font = '6px monospace';
|
||||
ctx.textAlign = 'left';
|
||||
ctx.fillText(NOTE_NAMES[n.note % 12], x + 2, y + ROW_H / 2 + 2);
|
||||
}
|
||||
}
|
||||
|
||||
// Playhead
|
||||
const currentPlayPos = playPosRef.current;
|
||||
if (currentPlayPos >= 0 && currentPlayPos < totalBeats) {
|
||||
const px = KEY_W + currentPlayPos * beatW;
|
||||
ctx.strokeStyle = '#ff6644';
|
||||
ctx.lineWidth = 2;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(px, 0); ctx.lineTo(px, h);
|
||||
ctx.stroke();
|
||||
|
||||
// Glow
|
||||
ctx.strokeStyle = 'rgba(255,102,68,0.2)';
|
||||
ctx.lineWidth = 6;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(px, 0); ctx.lineTo(px, h);
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
// Currently drawing note preview
|
||||
if (drawingRef.current) {
|
||||
const d = drawingRef.current;
|
||||
const row = MAX_NOTE - 1 - d.note;
|
||||
const x = KEY_W + d.start * beatW;
|
||||
const nw = d.duration * beatW;
|
||||
ctx.fillStyle = 'rgba(0,229,255,0.3)';
|
||||
ctx.fillRect(x, row * ROW_H, Math.max(nw, 2), ROW_H);
|
||||
}
|
||||
}, [totalBeats, beatW, rollW]);
|
||||
|
||||
// Animation loop
|
||||
useEffect(() => {
|
||||
const animate = () => {
|
||||
draw();
|
||||
rafRef.current = requestAnimationFrame(animate);
|
||||
};
|
||||
animate();
|
||||
return () => { if (rafRef.current) cancelAnimationFrame(rafRef.current); };
|
||||
}, [draw]);
|
||||
|
||||
// Subscribe to global master clock for playback
|
||||
const bpmRef = useRef(bpm);
|
||||
const loopRef = useRef(loop);
|
||||
const totalBeatsRef = useRef(totalBeats);
|
||||
bpmRef.current = bpm;
|
||||
loopRef.current = loop;
|
||||
totalBeatsRef.current = totalBeats;
|
||||
|
||||
useEffect(() => {
|
||||
if (!state.isRunning) {
|
||||
unsubscribeTick(`pr-${moduleId}`);
|
||||
playPosRef.current = -1;
|
||||
return;
|
||||
}
|
||||
|
||||
let currentNote = null;
|
||||
let lastQuantPos = -1;
|
||||
|
||||
subscribeTick(`pr-${moduleId}`, (time, ticks) => {
|
||||
const currentBpm = bpmRef.current;
|
||||
const currentLoop = loopRef.current;
|
||||
const currentTotalBeats = totalBeatsRef.current;
|
||||
|
||||
// Convert ticks to beats: each beat = MASTER_TICK_RATE * 60 / bpm ticks
|
||||
// Position in sixteenths: ticks / (ticksPerSixteenth)
|
||||
const ticksPerBeat = MASTER_TICK_RATE * 60 / currentBpm;
|
||||
const rawPos = ticks / ticksPerBeat; // in beats
|
||||
const pos = currentLoop ? rawPos % currentTotalBeats : rawPos;
|
||||
const quantPos = Math.floor(pos * 4) / 4;
|
||||
|
||||
if (quantPos === lastQuantPos) return;
|
||||
const looped = lastQuantPos >= 0 && quantPos < lastQuantPos;
|
||||
lastQuantPos = quantPos;
|
||||
|
||||
if (!currentLoop && rawPos >= currentTotalBeats) {
|
||||
if (currentNote) {
|
||||
setSequencerSignals(moduleId, 0, false);
|
||||
currentNote = null;
|
||||
}
|
||||
playPosRef.current = -1;
|
||||
return;
|
||||
}
|
||||
|
||||
playPosRef.current = pos;
|
||||
|
||||
if (looped && currentNote) {
|
||||
setSequencerSignals(moduleId, 0, false);
|
||||
currentNote = null;
|
||||
}
|
||||
|
||||
const allNotes = notesRef.current;
|
||||
const activeNote = allNotes.find(n => quantPos >= n.start && quantPos < n.start + n.duration);
|
||||
|
||||
if (activeNote) {
|
||||
if (!currentNote || currentNote.note !== activeNote.note || currentNote.start !== activeNote.start) {
|
||||
setSequencerSignals(moduleId, midiToFreq(activeNote.note), true);
|
||||
currentNote = activeNote;
|
||||
}
|
||||
} else {
|
||||
if (currentNote) {
|
||||
setSequencerSignals(moduleId, 0, false);
|
||||
currentNote = null;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
unsubscribeTick(`pr-${moduleId}`);
|
||||
};
|
||||
}, [state.isRunning, moduleId]);
|
||||
|
||||
// Mouse interaction for drawing/erasing notes
|
||||
const handleMouseDown = useCallback((e) => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
// Account for CSS transform scale (zoom) — rect is visual size, canvas is logical size
|
||||
const scaleX = canvas.width / rect.width;
|
||||
const scaleY = canvas.height / rect.height;
|
||||
const mx = (e.clientX - rect.left) * scaleX;
|
||||
const my = (e.clientY - rect.top) * scaleY;
|
||||
|
||||
if (mx < KEY_W) return; // Clicked on piano keys
|
||||
|
||||
const beat = (mx - KEY_W) / beatW;
|
||||
const noteIdx = MAX_NOTE - 1 - Math.floor(my / ROW_H);
|
||||
if (noteIdx < MIN_NOTE || noteIdx >= MAX_NOTE) return;
|
||||
|
||||
const snappedBeat = Math.floor(beat * 4) / 4; // Snap to 16th
|
||||
|
||||
if (tool === 'erase') {
|
||||
// Remove any note at this position
|
||||
const filtered = notes.filter(n =>
|
||||
!(n.note === noteIdx && snappedBeat >= n.start && snappedBeat < n.start + n.duration)
|
||||
);
|
||||
if (filtered.length !== notes.length) {
|
||||
mod.params._notes = filtered;
|
||||
notesRef.current = filtered;
|
||||
emit();
|
||||
}
|
||||
} else {
|
||||
// Check if clicking on existing note — remove it
|
||||
const existing = notes.findIndex(n =>
|
||||
n.note === noteIdx && snappedBeat >= n.start && snappedBeat < n.start + n.duration
|
||||
);
|
||||
if (existing >= 0) {
|
||||
const filtered = [...notes];
|
||||
filtered.splice(existing, 1);
|
||||
mod.params._notes = filtered;
|
||||
notesRef.current = filtered;
|
||||
emit();
|
||||
return;
|
||||
}
|
||||
|
||||
// Start drawing new note
|
||||
drawingRef.current = { note: noteIdx, start: snappedBeat, duration: 0.25 };
|
||||
|
||||
const handleMove = (me) => {
|
||||
if (!drawingRef.current) return;
|
||||
const mmx = (me.clientX - rect.left) * scaleX;
|
||||
const endBeat = Math.max(drawingRef.current.start + 0.25,
|
||||
Math.ceil(((mmx - KEY_W) / beatW) * 4) / 4);
|
||||
drawingRef.current.duration = endBeat - drawingRef.current.start;
|
||||
};
|
||||
|
||||
const handleUp = () => {
|
||||
if (drawingRef.current) {
|
||||
const newNote = { ...drawingRef.current };
|
||||
newNote.duration = Math.max(0.25, Math.min(newNote.duration, totalBeats - newNote.start));
|
||||
const updated = [...notes, newNote];
|
||||
mod.params._notes = updated;
|
||||
notesRef.current = updated;
|
||||
drawingRef.current = null;
|
||||
emit();
|
||||
}
|
||||
window.removeEventListener('pointermove', handleMove);
|
||||
window.removeEventListener('pointerup', handleUp);
|
||||
};
|
||||
|
||||
window.addEventListener('pointermove', handleMove);
|
||||
window.addEventListener('pointerup', handleUp);
|
||||
}
|
||||
}, [tool, notes, beatW, totalBeats, mod]);
|
||||
|
||||
const handleMidiImport = useCallback(async (e) => {
|
||||
const file = e.target.files[0];
|
||||
if (!file) return;
|
||||
try {
|
||||
const arrayBuffer = await file.arrayBuffer();
|
||||
const midi = parseMidi(arrayBuffer);
|
||||
if (midi.notes.length === 0) return;
|
||||
|
||||
// Update BPM if detected
|
||||
if (midi.bpm && mod) {
|
||||
mod.params.bpm = midi.bpm;
|
||||
}
|
||||
|
||||
// Auto-fit bars to cover all notes
|
||||
const maxBeat = Math.max(...midi.notes.map(n => n.start + n.duration));
|
||||
const neededBars = Math.ceil(maxBeat / 4);
|
||||
const fitBars = [1, 2, 4, 8].find(b => b >= neededBars) || 8;
|
||||
if (mod) mod.params.bars = String(fitBars);
|
||||
|
||||
// Set notes
|
||||
if (mod) {
|
||||
mod.params._notes = midi.notes;
|
||||
notesRef.current = midi.notes;
|
||||
emit();
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[PianoRoll] MIDI import failed:', err);
|
||||
}
|
||||
e.target.value = '';
|
||||
}, [mod]);
|
||||
|
||||
return (
|
||||
<div style={{ width: rollW }}>
|
||||
{/* Mini toolbar */}
|
||||
<div style={{ display: 'flex', gap: 4, marginBottom: 3 }}>
|
||||
<button
|
||||
style={{
|
||||
padding: '1px 6px', fontSize: 9, border: '1px solid',
|
||||
borderColor: tool === 'draw' ? '#00e5ff' : '#333',
|
||||
background: tool === 'draw' ? '#00e5ff' : '#111',
|
||||
color: tool === 'draw' ? '#000' : '#888',
|
||||
borderRadius: 3, cursor: 'pointer', fontFamily: 'inherit',
|
||||
}}
|
||||
onClick={() => setTool('draw')}
|
||||
>✏ Draw</button>
|
||||
<button
|
||||
style={{
|
||||
padding: '1px 6px', fontSize: 9, border: '1px solid',
|
||||
borderColor: tool === 'erase' ? '#ff4466' : '#333',
|
||||
background: tool === 'erase' ? '#ff4466' : '#111',
|
||||
color: tool === 'erase' ? '#000' : '#888',
|
||||
borderRadius: 3, cursor: 'pointer', fontFamily: 'inherit',
|
||||
}}
|
||||
onClick={() => setTool('erase')}
|
||||
>✕ Erase</button>
|
||||
<button
|
||||
style={{
|
||||
padding: '1px 6px', fontSize: 9, border: '1px solid #333',
|
||||
background: '#111', color: '#888',
|
||||
borderRadius: 3, cursor: 'pointer', fontFamily: 'inherit',
|
||||
}}
|
||||
onClick={() => midiInputRef.current?.click()}
|
||||
>🎵 MIDI</button>
|
||||
<input ref={midiInputRef} type="file" accept=".mid,.midi" style={{ display: 'none' }} onChange={handleMidiImport} />
|
||||
<span style={{ fontSize: 8, color: '#555', marginLeft: 'auto', alignSelf: 'center' }}>
|
||||
{notes.length} notes · {bars} bars
|
||||
</span>
|
||||
</div>
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
width={rollW}
|
||||
height={ROLL_H}
|
||||
style={{ width: rollW, height: ROLL_H, borderRadius: 4, cursor: tool === 'draw' ? 'crosshair' : 'pointer' }}
|
||||
onPointerDown={handleMouseDown}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
72
packages/client/src/components/PresetModal.jsx
Normal file
72
packages/client/src/components/PresetModal.jsx
Normal file
@@ -0,0 +1,72 @@
|
||||
import React, { useState } from 'react';
|
||||
import { getPresets, savePreset, loadPreset, deletePreset } from '../engine/presets.js';
|
||||
|
||||
export default function PresetModal({ mode, onClose }) {
|
||||
const [name, setName] = useState('');
|
||||
const presets = getPresets();
|
||||
|
||||
const handleSave = () => {
|
||||
if (!name.trim()) return;
|
||||
savePreset(name.trim());
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleLoad = (presetName) => {
|
||||
loadPreset(presetName);
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleDelete = (e, presetName) => {
|
||||
e.stopPropagation();
|
||||
deletePreset(presetName);
|
||||
// Force re-render
|
||||
setName(n => n + '');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="modal-overlay" onClick={onClose}>
|
||||
<div className="modal" onClick={e => e.stopPropagation()}>
|
||||
<h2>{mode === 'save' ? 'Save Preset' : 'Load Preset'}</h2>
|
||||
|
||||
{mode === 'save' && (
|
||||
<>
|
||||
<input
|
||||
autoFocus
|
||||
placeholder="Preset name..."
|
||||
value={name}
|
||||
onChange={e => setName(e.target.value)}
|
||||
onKeyDown={e => e.key === 'Enter' && handleSave()}
|
||||
/>
|
||||
<div className="modal-actions">
|
||||
<button onClick={onClose}>Cancel</button>
|
||||
<button className="primary" onClick={handleSave}>Save</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{mode === 'load' && (
|
||||
<>
|
||||
<div className="preset-list">
|
||||
{presets.length === 0 && (
|
||||
<div style={{ color: 'var(--text2)', padding: 12, textAlign: 'center' }}>No presets saved yet</div>
|
||||
)}
|
||||
{presets.map(p => (
|
||||
<div key={p.name} className="preset-item" onClick={() => handleLoad(p.name)}>
|
||||
<span>{p.name}</span>
|
||||
<span className="preset-date">{p.modules?.length || 0} modules</span>
|
||||
<button
|
||||
style={{ background: 'none', border: 'none', color: 'var(--red)', cursor: 'pointer', marginLeft: 8, fontSize: 12 }}
|
||||
onClick={e => handleDelete(e, p.name)}
|
||||
>✕</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="modal-actions">
|
||||
<button onClick={onClose}>Close</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
112
packages/client/src/components/ScopeDisplay.jsx
Normal file
112
packages/client/src/components/ScopeDisplay.jsx
Normal file
@@ -0,0 +1,112 @@
|
||||
import React, { useRef, useEffect, useState } from 'react';
|
||||
import { getAnalyserData } from '../engine/audioEngine.js';
|
||||
|
||||
// Zoom levels: how many samples to display (from a 2048-sample buffer)
|
||||
// Fewer samples = zoomed in (more detail), more samples = zoomed out (more time visible)
|
||||
const ZOOM_LEVELS = [64, 128, 256, 512, 1024, 2048];
|
||||
const DEFAULT_ZOOM = 2; // index → 256 samples
|
||||
|
||||
export default function ScopeDisplay({ moduleId }) {
|
||||
const canvasRef = useRef(null);
|
||||
const rafRef = useRef(null);
|
||||
const [zoomIdx, setZoomIdx] = useState(DEFAULT_ZOOM);
|
||||
const zoomRef = useRef(ZOOM_LEVELS[DEFAULT_ZOOM]);
|
||||
|
||||
// Keep ref in sync so the draw loop picks it up without re-creating the effect
|
||||
useEffect(() => { zoomRef.current = ZOOM_LEVELS[zoomIdx]; }, [zoomIdx]);
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
const ctx = canvas.getContext('2d');
|
||||
const w = canvas.width = 160;
|
||||
const h = canvas.height = 60;
|
||||
|
||||
let frameCount = 0;
|
||||
const draw = () => {
|
||||
frameCount++;
|
||||
rafRef.current = requestAnimationFrame(draw);
|
||||
// Throttle to ~30fps to reduce main thread pressure during playback
|
||||
if (frameCount % 2 !== 0) return;
|
||||
|
||||
ctx.fillStyle = '#050510';
|
||||
ctx.fillRect(0, 0, w, h);
|
||||
|
||||
// Grid lines
|
||||
ctx.strokeStyle = '#151530';
|
||||
ctx.lineWidth = 0.5;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(0, h / 2); ctx.lineTo(w, h / 2);
|
||||
ctx.moveTo(0, h / 4); ctx.lineTo(w, h / 4);
|
||||
ctx.moveTo(0, h * 3 / 4); ctx.lineTo(w, h * 3 / 4);
|
||||
for (let x = w / 4; x < w; x += w / 4) {
|
||||
ctx.moveTo(x, 0); ctx.lineTo(x, h);
|
||||
}
|
||||
ctx.stroke();
|
||||
|
||||
const data = getAnalyserData(moduleId);
|
||||
if (data && data.length > 0) {
|
||||
const samplesToShow = zoomRef.current;
|
||||
// Center the window in the buffer
|
||||
const offset = Math.max(0, Math.floor((data.length - samplesToShow) / 2));
|
||||
const end = Math.min(data.length, offset + samplesToShow);
|
||||
|
||||
ctx.strokeStyle = '#00e5ff';
|
||||
ctx.lineWidth = 1.5;
|
||||
ctx.beginPath();
|
||||
const count = end - offset;
|
||||
const step = w / count;
|
||||
for (let i = 0; i < count; i++) {
|
||||
const y = h / 2 + data[offset + i] * h / 2 * -1;
|
||||
if (i === 0) ctx.moveTo(0, y);
|
||||
else ctx.lineTo(i * step, y);
|
||||
}
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
};
|
||||
draw();
|
||||
|
||||
return () => { if (rafRef.current) cancelAnimationFrame(rafRef.current); };
|
||||
}, [moduleId]);
|
||||
|
||||
const canZoomIn = zoomIdx > 0;
|
||||
const canZoomOut = zoomIdx < ZOOM_LEVELS.length - 1;
|
||||
|
||||
return (
|
||||
<div style={{ position: 'relative' }}>
|
||||
<canvas ref={canvasRef} className="scope-canvas" />
|
||||
<div style={{
|
||||
position: 'absolute', bottom: 2, right: 2,
|
||||
display: 'flex', gap: 2,
|
||||
}}>
|
||||
<button
|
||||
onClick={() => canZoomOut && setZoomIdx(i => i + 1)}
|
||||
disabled={!canZoomOut}
|
||||
title="Zoom out (más tiempo)"
|
||||
style={{
|
||||
width: 18, height: 18, padding: 0,
|
||||
background: canZoomOut ? '#1a1a3a' : '#0a0a15',
|
||||
border: '1px solid #333', borderRadius: 3,
|
||||
color: canZoomOut ? '#00e5ff' : '#333',
|
||||
cursor: canZoomOut ? 'pointer' : 'default',
|
||||
fontSize: 12, lineHeight: '16px', fontWeight: 'bold',
|
||||
}}
|
||||
>−</button>
|
||||
<button
|
||||
onClick={() => canZoomIn && setZoomIdx(i => i - 1)}
|
||||
disabled={!canZoomIn}
|
||||
title="Zoom in (más detalle)"
|
||||
style={{
|
||||
width: 18, height: 18, padding: 0,
|
||||
background: canZoomIn ? '#1a1a3a' : '#0a0a15',
|
||||
border: '1px solid #333', borderRadius: 3,
|
||||
color: canZoomIn ? '#00e5ff' : '#333',
|
||||
cursor: canZoomIn ? 'pointer' : 'default',
|
||||
fontSize: 12, lineHeight: '16px', fontWeight: 'bold',
|
||||
}}
|
||||
>+</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
185
packages/client/src/components/SequencerWidget.jsx
Normal file
185
packages/client/src/components/SequencerWidget.jsx
Normal file
@@ -0,0 +1,185 @@
|
||||
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import * as Tone from 'tone';
|
||||
import { state, updateModuleParam, emit } from '../engine/state.js';
|
||||
import { setSequencerSignals, getAudioNode, subscribeTick, unsubscribeTick, MASTER_TICK_RATE } from '../engine/audioEngine.js';
|
||||
|
||||
const NOTE_NAMES = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B'];
|
||||
|
||||
function midiToFreq(midi) { return 440 * Math.pow(2, (midi - 69) / 12); }
|
||||
function noteLabel(midi) { return NOTE_NAMES[midi % 12] + Math.floor(midi / 12 - 1); }
|
||||
|
||||
const DEFAULT_STEPS = [
|
||||
{ midi: 60, gate: true }, { midi: 63, gate: true }, { midi: 65, gate: false }, { midi: 67, gate: true },
|
||||
{ midi: 70, gate: true }, { midi: 67, gate: true }, { midi: 63, gate: false }, { midi: 60, gate: true },
|
||||
{ midi: 58, gate: true }, { midi: 60, gate: true }, { midi: 63, gate: false }, { midi: 65, gate: true },
|
||||
{ midi: 67, gate: true }, { midi: 72, gate: true }, { midi: 70, gate: false }, { midi: 67, gate: true },
|
||||
];
|
||||
|
||||
export default function SequencerWidget({ moduleId }) {
|
||||
const mod = state.modules.find(m => m.id === moduleId);
|
||||
const currentStepRef = useRef(-1);
|
||||
const [visualStep, setVisualStep] = useState(-1);
|
||||
const stepsRef = useRef(null);
|
||||
const rafRef = useRef(null);
|
||||
|
||||
// Init steps data
|
||||
const numSteps = parseInt(mod?.params?.steps || '16');
|
||||
if (mod) {
|
||||
if (!mod.params._steps) {
|
||||
const initial = DEFAULT_STEPS.slice(0, numSteps);
|
||||
while (initial.length < numSteps) initial.push({ midi: 60, gate: false });
|
||||
mod.params._steps = initial;
|
||||
} else if (mod.params._steps.length < numSteps) {
|
||||
while (mod.params._steps.length < numSteps) {
|
||||
mod.params._steps.push({ midi: 60, gate: false });
|
||||
}
|
||||
} else if (mod.params._steps.length > numSteps) {
|
||||
mod.params._steps = mod.params._steps.slice(0, numSteps);
|
||||
}
|
||||
}
|
||||
const steps = mod?.params?._steps || DEFAULT_STEPS;
|
||||
stepsRef.current = steps;
|
||||
|
||||
const bpm = mod?.params?.bpm ?? 140;
|
||||
|
||||
// Visual update loop — decoupled from audio, uses RAF
|
||||
useEffect(() => {
|
||||
const tick = () => {
|
||||
setVisualStep(currentStepRef.current);
|
||||
rafRef.current = requestAnimationFrame(tick);
|
||||
};
|
||||
if (state.isRunning) {
|
||||
rafRef.current = requestAnimationFrame(tick);
|
||||
}
|
||||
return () => {
|
||||
if (rafRef.current) cancelAnimationFrame(rafRef.current);
|
||||
};
|
||||
}, [state.isRunning]);
|
||||
|
||||
// Subscribe to global master clock — derive step from elapsed time
|
||||
const bpmRef = useRef(bpm);
|
||||
const numStepsRef = useRef(numSteps);
|
||||
bpmRef.current = bpm;
|
||||
numStepsRef.current = numSteps;
|
||||
|
||||
useEffect(() => {
|
||||
if (!state.isRunning) {
|
||||
unsubscribeTick(`seq-${moduleId}`);
|
||||
currentStepRef.current = -1;
|
||||
setVisualStep(-1);
|
||||
return;
|
||||
}
|
||||
|
||||
let lastStepIdx = -1;
|
||||
let lastGateOn = false;
|
||||
|
||||
subscribeTick(`seq-${moduleId}`, (time, ticks) => {
|
||||
const currentBpm = bpmRef.current;
|
||||
const currentNumSteps = numStepsRef.current;
|
||||
// ticksPerStep = MASTER_TICK_RATE / sixteenthsPerSecond
|
||||
// sixteenthsPerSecond = bpm * 4 / 60
|
||||
const ticksPerStep = MASTER_TICK_RATE * 60 / (currentBpm * 4);
|
||||
const stepIdx = Math.floor(ticks / ticksPerStep) % currentNumSteps;
|
||||
|
||||
if (stepIdx === lastStepIdx) return;
|
||||
lastStepIdx = stepIdx;
|
||||
|
||||
// Turn off previous note at step boundary (no setTimeout needed)
|
||||
if (lastGateOn) {
|
||||
setSequencerSignals(moduleId, 0, false);
|
||||
lastGateOn = false;
|
||||
}
|
||||
|
||||
const s = stepsRef.current[stepIdx];
|
||||
if (!s) return;
|
||||
|
||||
currentStepRef.current = stepIdx;
|
||||
|
||||
if (s.gate) {
|
||||
setSequencerSignals(moduleId, midiToFreq(s.midi), true);
|
||||
lastGateOn = true;
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
unsubscribeTick(`seq-${moduleId}`);
|
||||
};
|
||||
}, [state.isRunning, moduleId]);
|
||||
|
||||
const toggleGate = (idx) => {
|
||||
steps[idx] = { ...steps[idx], gate: !steps[idx].gate };
|
||||
updateModuleParam(moduleId, '_steps', [...steps]);
|
||||
stepsRef.current = steps;
|
||||
};
|
||||
|
||||
const changeNote = (idx, delta) => {
|
||||
const newMidi = Math.max(36, Math.min(96, steps[idx].midi + delta));
|
||||
steps[idx] = { ...steps[idx], midi: newMidi };
|
||||
updateModuleParam(moduleId, '_steps', [...steps]);
|
||||
stepsRef.current = steps;
|
||||
emit();
|
||||
};
|
||||
|
||||
const CELL_W = 18;
|
||||
const CELL_H = 50;
|
||||
const W = CELL_W * numSteps;
|
||||
const H = CELL_H + 16;
|
||||
|
||||
return (
|
||||
<div style={{ width: W + 4, overflow: 'hidden' }}>
|
||||
<svg viewBox={`0 0 ${W} ${H}`} style={{ width: W, height: H, display: 'block' }}>
|
||||
{steps.slice(0, numSteps).map((s, i) => {
|
||||
const x = i * CELL_W;
|
||||
const isActive = i === visualStep;
|
||||
const barHeight = ((s.midi - 36) / 60) * (CELL_H - 4);
|
||||
|
||||
return (
|
||||
<g key={i}>
|
||||
<rect x={x + 1} y={0} width={CELL_W - 2} height={CELL_H}
|
||||
rx={2} fill={isActive ? '#1a2a40' : '#0c0c18'}
|
||||
stroke={isActive ? '#00e5ff' : '#222'} strokeWidth={isActive ? 1.5 : 0.5}
|
||||
/>
|
||||
{s.gate && (
|
||||
<rect x={x + 3} y={CELL_H - barHeight - 2} width={CELL_W - 6} height={barHeight}
|
||||
rx={1}
|
||||
fill={isActive ? '#00e5ff' : '#0088aa'}
|
||||
opacity={0.9}
|
||||
/>
|
||||
)}
|
||||
{!s.gate && (
|
||||
<line x1={x + CELL_W / 2} y1={CELL_H / 2 - 3} x2={x + CELL_W / 2} y2={CELL_H / 2 + 3}
|
||||
stroke="#333" strokeWidth={1.5} />
|
||||
)}
|
||||
<text x={x + CELL_W / 2} y={CELL_H + 10} textAnchor="middle"
|
||||
fontSize={6} fill={s.gate ? '#88ccff' : '#333'} fontFamily="monospace">
|
||||
{noteLabel(s.midi)}
|
||||
</text>
|
||||
<rect x={x} y={0} width={CELL_W} height={CELL_H / 3}
|
||||
fill="transparent" style={{ cursor: 'pointer' }}
|
||||
onClick={() => changeNote(i, 1)}
|
||||
/>
|
||||
<rect x={x} y={CELL_H / 3} width={CELL_W} height={CELL_H / 3}
|
||||
fill="transparent" style={{ cursor: 'pointer' }}
|
||||
onClick={() => toggleGate(i)}
|
||||
/>
|
||||
<rect x={x} y={CELL_H * 2 / 3} width={CELL_W} height={CELL_H / 3}
|
||||
fill="transparent" style={{ cursor: 'pointer' }}
|
||||
onClick={() => changeNote(i, -1)}
|
||||
/>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
{visualStep >= 0 && (
|
||||
<line
|
||||
x1={visualStep * CELL_W + CELL_W / 2} y1={0}
|
||||
x2={visualStep * CELL_W + CELL_W / 2} y2={CELL_H}
|
||||
stroke="#00e5ff" strokeWidth={1} opacity={0.4}
|
||||
/>
|
||||
)}
|
||||
</svg>
|
||||
<div style={{ fontSize: 8, color: '#555', textAlign: 'center', marginTop: 2 }}>
|
||||
↑top/↓bot: pitch · mid: toggle
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
74
packages/client/src/components/WireLayer.jsx
Normal file
74
packages/client/src/components/WireLayer.jsx
Normal file
@@ -0,0 +1,74 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { wirePath } from '../utils/bezier.js';
|
||||
import { state, removeConnection } from '../engine/state.js';
|
||||
import { disconnectWire } from '../engine/audioEngine.js';
|
||||
import { getModuleDef, PORT_TYPE } from '../engine/moduleRegistry.js';
|
||||
|
||||
export default function WireLayer({ portPositions, tempWire, containerRef, zoom, camX, camY }) {
|
||||
// Force a second render after DOM commit so getBoundingClientRect reads correct positions
|
||||
// This fixes wires lagging behind after zoom, pan, or level re-entry
|
||||
const [, refreshWires] = useState(0);
|
||||
const connCount = state.connections.length;
|
||||
const modCount = state.modules.length;
|
||||
useEffect(() => {
|
||||
const id = requestAnimationFrame(() => refreshWires(n => n + 1));
|
||||
return () => cancelAnimationFrame(id);
|
||||
}, [zoom, camX, camY, connCount, modCount]);
|
||||
const getPortPos = (moduleId, portName, direction) => {
|
||||
const key = `${moduleId}-${portName}-${direction}`;
|
||||
const el = portPositions.current[key];
|
||||
if (!el || !containerRef.current) return null;
|
||||
const rect = el.getBoundingClientRect();
|
||||
const containerRect = containerRef.current.getBoundingClientRect();
|
||||
return {
|
||||
x: rect.left + rect.width / 2 - containerRect.left,
|
||||
y: rect.top + rect.height / 2 - containerRect.top,
|
||||
};
|
||||
};
|
||||
|
||||
const getPortType = (moduleId, portName, direction) => {
|
||||
const mod = state.modules.find(m => m.id === moduleId);
|
||||
if (!mod) return PORT_TYPE.AUDIO;
|
||||
const def = getModuleDef(mod.type);
|
||||
if (!def) return PORT_TYPE.AUDIO;
|
||||
const ports = direction === 'output' ? def.outputs : def.inputs;
|
||||
const port = ports.find(p => p.name === portName);
|
||||
return port?.type || PORT_TYPE.AUDIO;
|
||||
};
|
||||
|
||||
const handleWireClick = (conn) => {
|
||||
disconnectWire(conn);
|
||||
removeConnection(conn.id);
|
||||
};
|
||||
|
||||
return (
|
||||
<svg className="wires-svg">
|
||||
{/* Existing connections */}
|
||||
{state.connections.map(conn => {
|
||||
const from = getPortPos(conn.from.moduleId, conn.from.port, 'output');
|
||||
const to = getPortPos(conn.to.moduleId, conn.to.port, 'input');
|
||||
if (!from || !to) return null;
|
||||
|
||||
const portType = getPortType(conn.from.moduleId, conn.from.port, 'output');
|
||||
|
||||
return (
|
||||
<path
|
||||
key={conn.id}
|
||||
className={portType}
|
||||
d={wirePath(from.x, from.y, to.x, to.y)}
|
||||
style={{ pointerEvents: 'stroke', cursor: 'pointer' }}
|
||||
onClick={() => handleWireClick(conn)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Temp wire while connecting */}
|
||||
{tempWire && (
|
||||
<path
|
||||
className={`${tempWire.portType} temp`}
|
||||
d={wirePath(tempWire.startX, tempWire.startY, tempWire.endX, tempWire.endY)}
|
||||
/>
|
||||
)}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
609
packages/client/src/engine/audioEngine.js
Normal file
609
packages/client/src/engine/audioEngine.js
Normal file
@@ -0,0 +1,609 @@
|
||||
/**
|
||||
* audioEngine.js — Bridge between node graph state and Tone.js audio graph
|
||||
* Creates, connects, and destroys Tone.js nodes as the user edits the patch
|
||||
*/
|
||||
import * as Tone from 'tone';
|
||||
import { state } from './state.js';
|
||||
import { getModuleDef } from './moduleRegistry.js';
|
||||
|
||||
// Map moduleId → { node: Tone.js node, inputs: {portName: node/param}, outputs: {portName: node} }
|
||||
const audioNodes = {};
|
||||
|
||||
// Active keyboard state
|
||||
const keyboardState = { frequency: 440, gate: false };
|
||||
|
||||
// ==================== Global Master Clock ====================
|
||||
// Single clock with integer tick counter. All sequencers/piano rolls
|
||||
// derive their step positions from this shared tick count.
|
||||
// Using integers avoids floating-point drift entirely.
|
||||
export const MASTER_TICK_RATE = 120; // Hz — 6x headroom for 300 BPM sixteenths (20 Hz). Lower = less main thread pressure.
|
||||
let _masterClock = null;
|
||||
const _tickListeners = new Map(); // id → callback(audioTime, ticks)
|
||||
|
||||
export function subscribeTick(id, callback) {
|
||||
_tickListeners.set(id, callback);
|
||||
}
|
||||
|
||||
export function unsubscribeTick(id) {
|
||||
_tickListeners.delete(id);
|
||||
}
|
||||
|
||||
function startMasterClock() {
|
||||
if (_masterClock) return;
|
||||
let _startTime = 0;
|
||||
let _started = false;
|
||||
_masterClock = new Tone.Clock((time) => {
|
||||
if (!_started) { _startTime = time; _started = true; }
|
||||
// Derive ticks from precise AudioContext.currentTime, not a counter.
|
||||
// Counters fall behind when callbacks are delayed (GC, UI, tab throttle).
|
||||
// The time parameter is always accurate regardless of callback jitter.
|
||||
const ticks = Math.round((time - _startTime) * MASTER_TICK_RATE);
|
||||
for (const cb of _tickListeners.values()) {
|
||||
cb(time, ticks);
|
||||
}
|
||||
}, MASTER_TICK_RATE);
|
||||
_masterClock.start();
|
||||
}
|
||||
|
||||
function stopMasterClock() {
|
||||
if (_masterClock) {
|
||||
try { _masterClock.stop(); } catch {}
|
||||
try { _masterClock.dispose(); } catch {}
|
||||
_masterClock = null;
|
||||
}
|
||||
_tickListeners.clear();
|
||||
}
|
||||
|
||||
// ==================== Node creation ====================
|
||||
|
||||
function createNode(mod) {
|
||||
const def = getModuleDef(mod.type);
|
||||
if (!def) return null;
|
||||
|
||||
const p = { ...Object.fromEntries(Object.entries(def.params).map(([k, v]) => [k, v.default])), ...mod.params };
|
||||
|
||||
switch (mod.type) {
|
||||
case 'oscillator': {
|
||||
const osc = new Tone.Oscillator({ type: p.waveform, frequency: p.frequency, detune: p.detune });
|
||||
osc.start();
|
||||
// Modulation scaler for freq input: LFO (-1..1) × scale → added to osc.frequency
|
||||
// Scale = half the current frequency so modulation is musically meaningful
|
||||
const freqMod = new Tone.Gain(p.frequency * 0.5);
|
||||
freqMod.connect(osc.frequency);
|
||||
return {
|
||||
node: osc,
|
||||
_freqMod: freqMod,
|
||||
inputs: { freq: freqMod, detune: osc.detune },
|
||||
outputs: { out: osc },
|
||||
dispose: () => { osc.stop(); freqMod.disconnect(); freqMod.dispose(); osc.dispose(); },
|
||||
};
|
||||
}
|
||||
case 'lfo': {
|
||||
const lfo = new Tone.LFO({ type: p.waveform, frequency: p.frequency, amplitude: p.amplitude, min: -1, max: 1 });
|
||||
lfo.start();
|
||||
return {
|
||||
node: lfo,
|
||||
inputs: {},
|
||||
outputs: { out: lfo },
|
||||
dispose: () => { lfo.stop(); lfo.dispose(); },
|
||||
};
|
||||
}
|
||||
case 'noise': {
|
||||
const noise = new Tone.Noise(p.type);
|
||||
noise.start();
|
||||
return {
|
||||
node: noise,
|
||||
inputs: {},
|
||||
outputs: { out: noise },
|
||||
dispose: () => { noise.stop(); noise.dispose(); },
|
||||
};
|
||||
}
|
||||
case 'filter': {
|
||||
const filter = new Tone.Filter({ type: p.type, frequency: p.frequency, Q: p.Q });
|
||||
// Modulation scaler for cutoff input: LFO (-1..1) × scale → added to filter.frequency
|
||||
// Scale = cutoff value so full LFO sweep covers 0 to 2× the cutoff
|
||||
const cutoffMod = new Tone.Gain(p.frequency);
|
||||
cutoffMod.connect(filter.frequency);
|
||||
return {
|
||||
node: filter,
|
||||
_cutoffMod: cutoffMod,
|
||||
inputs: { in: filter, cutoff: cutoffMod },
|
||||
outputs: { out: filter },
|
||||
dispose: () => { cutoffMod.disconnect(); cutoffMod.dispose(); filter.dispose(); },
|
||||
};
|
||||
}
|
||||
case 'envelope': {
|
||||
const env = new Tone.Envelope({ attack: p.attack, decay: p.decay, sustain: p.sustain, release: p.release });
|
||||
// Connect env to a signal so it can be used as modulation source
|
||||
const sig = new Tone.Signal(0);
|
||||
env.connect(sig);
|
||||
return {
|
||||
node: env,
|
||||
_sig: sig,
|
||||
inputs: { gate: null }, // Gate is handled via triggerAttack/Release
|
||||
outputs: { out: sig },
|
||||
dispose: () => { env.dispose(); sig.dispose(); },
|
||||
};
|
||||
}
|
||||
case 'vca': {
|
||||
const gain = new Tone.Gain(p.gain);
|
||||
// CV scaler: always gain=1 so envelope (0-1) passes through fully.
|
||||
// When CV is connected, base gain is zeroed — envelope controls amplitude entirely.
|
||||
const cvMod = new Tone.Gain(1);
|
||||
cvMod.connect(gain.gain);
|
||||
return {
|
||||
node: gain,
|
||||
_cvMod: cvMod,
|
||||
inputs: { in: gain, cv: cvMod },
|
||||
outputs: { out: gain },
|
||||
dispose: () => { cvMod.disconnect(); cvMod.dispose(); gain.dispose(); },
|
||||
};
|
||||
}
|
||||
case 'delay': {
|
||||
const delay = new Tone.FeedbackDelay({ delayTime: p.delayTime, feedback: p.feedback, wet: p.wet });
|
||||
return {
|
||||
node: delay,
|
||||
inputs: { in: delay },
|
||||
outputs: { out: delay },
|
||||
dispose: () => delay.dispose(),
|
||||
};
|
||||
}
|
||||
case 'reverb': {
|
||||
const reverb = new Tone.Reverb({ decay: p.decay, wet: p.wet });
|
||||
return {
|
||||
node: reverb,
|
||||
inputs: { in: reverb },
|
||||
outputs: { out: reverb },
|
||||
dispose: () => reverb.dispose(),
|
||||
};
|
||||
}
|
||||
case 'distortion': {
|
||||
const dist = new Tone.Distortion({ distortion: p.distortion, wet: p.wet });
|
||||
return {
|
||||
node: dist,
|
||||
inputs: { in: dist },
|
||||
outputs: { out: dist },
|
||||
dispose: () => dist.dispose(),
|
||||
};
|
||||
}
|
||||
case 'mixer': {
|
||||
const master = new Tone.Gain(1);
|
||||
const ch1 = new Tone.Gain(p.gain1);
|
||||
const ch2 = new Tone.Gain(p.gain2);
|
||||
const ch3 = new Tone.Gain(p.gain3);
|
||||
const ch4 = new Tone.Gain(p.gain4);
|
||||
ch1.connect(master); ch2.connect(master); ch3.connect(master); ch4.connect(master);
|
||||
return {
|
||||
node: master,
|
||||
_channels: [ch1, ch2, ch3, ch4],
|
||||
inputs: { in1: ch1, in2: ch2, in3: ch3, in4: ch4 },
|
||||
outputs: { out: master },
|
||||
dispose: () => { [ch1, ch2, ch3, ch4, master].forEach(n => n.dispose()); },
|
||||
};
|
||||
}
|
||||
case 'scope': {
|
||||
const analyser = new Tone.Analyser('waveform', 2048);
|
||||
return {
|
||||
node: analyser,
|
||||
inputs: { in: analyser },
|
||||
outputs: {},
|
||||
analyser,
|
||||
dispose: () => analyser.dispose(),
|
||||
};
|
||||
}
|
||||
case 'cv2gate': {
|
||||
// Converts a continuous CV signal to gate on/off based on threshold.
|
||||
// Uses an analyser to read the CV value and triggers connected envelopes.
|
||||
const analyser = new Tone.Analyser('waveform', 32);
|
||||
const gateSig = new Tone.Signal(0);
|
||||
return {
|
||||
node: analyser,
|
||||
_gateSig: gateSig,
|
||||
_gateState: false,
|
||||
inputs: { in: analyser },
|
||||
outputs: { gate: gateSig },
|
||||
dispose: () => { analyser.dispose(); gateSig.dispose(); },
|
||||
};
|
||||
}
|
||||
case 'output': {
|
||||
// True stereo output: separate left/right channels → merge → master gain → destination
|
||||
const leftGain = new Tone.Gain(1);
|
||||
const rightGain = new Tone.Gain(1);
|
||||
const merge = new Tone.Merge();
|
||||
const master = new Tone.Gain(Tone.dbToGain(p.volume));
|
||||
leftGain.connect(merge, 0, 0);
|
||||
rightGain.connect(merge, 0, 1);
|
||||
merge.connect(master);
|
||||
master.toDestination();
|
||||
return {
|
||||
node: master,
|
||||
_merge: merge,
|
||||
_leftGain: leftGain,
|
||||
_rightGain: rightGain,
|
||||
inputs: { left: leftGain, right: rightGain },
|
||||
outputs: {},
|
||||
dispose: () => {
|
||||
leftGain.disconnect(); leftGain.dispose();
|
||||
rightGain.disconnect(); rightGain.dispose();
|
||||
merge.disconnect(); merge.dispose();
|
||||
master.disconnect(); master.dispose();
|
||||
},
|
||||
};
|
||||
}
|
||||
case 'keyboard':
|
||||
case 'drumpad': {
|
||||
const freqSig = new Tone.Signal(440);
|
||||
const gateSig = new Tone.Signal(0);
|
||||
return {
|
||||
node: null,
|
||||
inputs: {},
|
||||
outputs: { freq: freqSig, gate: gateSig },
|
||||
_freqSig: freqSig,
|
||||
_gateSig: gateSig,
|
||||
dispose: () => { freqSig.dispose(); gateSig.dispose(); },
|
||||
};
|
||||
}
|
||||
case 'sequencer': {
|
||||
const freqSig = new Tone.Signal(440);
|
||||
const gateSig = new Tone.Signal(0);
|
||||
// Sequencer loop managed externally by SequencerWidget
|
||||
return {
|
||||
node: null,
|
||||
inputs: {},
|
||||
outputs: { freq: freqSig, gate: gateSig },
|
||||
_freqSig: freqSig,
|
||||
_gateSig: gateSig,
|
||||
_seq: null, // Tone.Sequence set by widget
|
||||
dispose: () => {
|
||||
freqSig.dispose(); gateSig.dispose();
|
||||
},
|
||||
};
|
||||
}
|
||||
case 'pianoroll': {
|
||||
const freqSig = new Tone.Signal(440);
|
||||
const gateSig = new Tone.Signal(0);
|
||||
return {
|
||||
node: null,
|
||||
inputs: {},
|
||||
outputs: { freq: freqSig, gate: gateSig },
|
||||
_freqSig: freqSig,
|
||||
_gateSig: gateSig,
|
||||
_part: null, // Tone.Part set by widget
|
||||
dispose: () => {
|
||||
freqSig.dispose(); gateSig.dispose();
|
||||
},
|
||||
};
|
||||
}
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== Public API ====================
|
||||
|
||||
export function ensureNode(moduleId) {
|
||||
if (audioNodes[moduleId]) return audioNodes[moduleId];
|
||||
const mod = state.modules.find(m => m.id === moduleId);
|
||||
if (!mod) return null;
|
||||
const node = createNode(mod);
|
||||
if (node) audioNodes[moduleId] = node;
|
||||
return node;
|
||||
}
|
||||
|
||||
export function getAudioNode(moduleId) {
|
||||
return audioNodes[moduleId] || null;
|
||||
}
|
||||
|
||||
export function destroyNode(moduleId) {
|
||||
const entry = audioNodes[moduleId];
|
||||
if (!entry) return;
|
||||
try { entry.dispose(); } catch (e) { console.warn('dispose error', e); }
|
||||
delete audioNodes[moduleId];
|
||||
}
|
||||
|
||||
export function connectWire(conn) {
|
||||
const fromEntry = ensureNode(conn.from.moduleId);
|
||||
const toEntry = ensureNode(conn.to.moduleId);
|
||||
if (!fromEntry || !toEntry) return;
|
||||
|
||||
// Skip audio-graph connection for keyboard/sequencer/pianoroll freq → oscillator freq.
|
||||
// These signals carry absolute Hz values that would be mangled by the oscillator's
|
||||
// frequency-modulation Gain scaler. Instead, triggerKeyboard / setSequencerSignals
|
||||
// set the oscillator frequency directly when notes are played.
|
||||
const fromMod = state.modules.find(m => m.id === conn.from.moduleId);
|
||||
const toMod = state.modules.find(m => m.id === conn.to.moduleId);
|
||||
if (fromMod && ['keyboard', 'drumpad', 'sequencer', 'pianoroll'].includes(fromMod.type) &&
|
||||
conn.from.port === 'freq' && toMod?.type === 'oscillator' && conn.to.port === 'freq') {
|
||||
return; // handled imperatively in triggerKeyboard / setSequencerSignals
|
||||
}
|
||||
|
||||
const output = fromEntry.outputs[conn.from.port];
|
||||
const input = toEntry.inputs[conn.to.port];
|
||||
if (!output || input === undefined || input === null) return;
|
||||
|
||||
try {
|
||||
if (typeof output.connect === 'function') {
|
||||
output.connect(input);
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('connect error', e);
|
||||
}
|
||||
|
||||
// When CV is connected to VCA, zero the base gain so only envelope controls it
|
||||
if (toMod?.type === 'vca' && conn.to.port === 'cv') {
|
||||
toEntry.node.gain.value = 0;
|
||||
}
|
||||
}
|
||||
|
||||
export function disconnectWire(conn) {
|
||||
const fromEntry = audioNodes[conn.from.moduleId];
|
||||
const toEntry = audioNodes[conn.to.moduleId];
|
||||
if (!fromEntry || !toEntry) return;
|
||||
|
||||
const output = fromEntry.outputs[conn.from.port];
|
||||
const input = toEntry.inputs[conn.to.port];
|
||||
if (!output || !input) return;
|
||||
|
||||
try {
|
||||
if (typeof output.disconnect === 'function') {
|
||||
output.disconnect(input);
|
||||
}
|
||||
} catch (e) {
|
||||
// Tone.js may throw if not connected
|
||||
}
|
||||
|
||||
// When CV is disconnected from VCA, restore base gain from params
|
||||
const toMod = state.modules.find(m => m.id === conn.to.moduleId);
|
||||
if (toMod?.type === 'vca' && conn.to.port === 'cv') {
|
||||
toEntry.node.gain.value = toMod.params?.gain ?? 0.8;
|
||||
}
|
||||
}
|
||||
|
||||
export function updateParam(moduleId, paramName, value) {
|
||||
const entry = audioNodes[moduleId];
|
||||
const mod = state.modules.find(m => m.id === moduleId);
|
||||
if (!entry || !mod) return;
|
||||
|
||||
const def = getModuleDef(mod.type);
|
||||
if (!def) return;
|
||||
|
||||
switch (mod.type) {
|
||||
case 'oscillator':
|
||||
if (paramName === 'waveform') entry.node.type = value;
|
||||
else if (paramName === 'frequency') {
|
||||
entry.node.frequency.value = value;
|
||||
// Update mod scaler proportionally
|
||||
if (entry._freqMod) entry._freqMod.gain.value = value * 0.5;
|
||||
}
|
||||
else if (paramName === 'detune') entry.node.detune.value = value;
|
||||
break;
|
||||
case 'lfo':
|
||||
if (paramName === 'waveform') entry.node.type = value;
|
||||
else if (paramName === 'frequency') entry.node.frequency.value = value;
|
||||
else if (paramName === 'amplitude') entry.node.amplitude.value = value;
|
||||
break;
|
||||
case 'noise':
|
||||
if (paramName === 'type') entry.node.type = value;
|
||||
break;
|
||||
case 'filter':
|
||||
if (paramName === 'type') entry.node.type = value;
|
||||
else if (paramName === 'frequency') {
|
||||
entry.node.frequency.value = value;
|
||||
// Update mod scaler proportionally
|
||||
if (entry._cutoffMod) entry._cutoffMod.gain.value = value;
|
||||
}
|
||||
else if (paramName === 'Q') entry.node.Q.value = value;
|
||||
break;
|
||||
case 'envelope':
|
||||
if (paramName === 'attack') entry.node.attack = value;
|
||||
else if (paramName === 'decay') entry.node.decay = value;
|
||||
else if (paramName === 'sustain') entry.node.sustain = value;
|
||||
else if (paramName === 'release') entry.node.release = value;
|
||||
break;
|
||||
case 'vca':
|
||||
if (paramName === 'gain') {
|
||||
// Only update base gain if no CV is connected (CV zeroes it)
|
||||
const hasCV = state.connections.some(c => c.to.moduleId === moduleId && c.to.port === 'cv');
|
||||
if (!hasCV) entry.node.gain.value = value;
|
||||
// cvMod stays at 1 always — envelope controls full range
|
||||
}
|
||||
break;
|
||||
case 'delay':
|
||||
if (paramName === 'delayTime') entry.node.delayTime.value = value;
|
||||
else if (paramName === 'feedback') entry.node.feedback.value = value;
|
||||
else if (paramName === 'wet') entry.node.wet.value = value;
|
||||
break;
|
||||
case 'reverb':
|
||||
if (paramName === 'decay') entry.node.decay = value;
|
||||
else if (paramName === 'wet') entry.node.wet.value = value;
|
||||
break;
|
||||
case 'distortion':
|
||||
if (paramName === 'distortion') entry.node.distortion = value;
|
||||
else if (paramName === 'wet') entry.node.wet.value = value;
|
||||
break;
|
||||
case 'mixer':
|
||||
if (paramName.startsWith('gain')) {
|
||||
const idx = parseInt(paramName.replace('gain', '')) - 1;
|
||||
if (entry._channels && entry._channels[idx]) entry._channels[idx].gain.value = value;
|
||||
}
|
||||
break;
|
||||
case 'output':
|
||||
if (paramName === 'volume') entry.node.gain.value = Tone.dbToGain(value);
|
||||
break;
|
||||
case 'keyboard':
|
||||
case 'drumpad':
|
||||
case 'cv2gate':
|
||||
case 'sequencer':
|
||||
case 'pianoroll':
|
||||
// All params stored in state, managed by widgets
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Cache connection lookups for hot-path audio scheduling
|
||||
// Rebuilt only when connections actually change (dirty flag, no computation on hit)
|
||||
let _connCacheDirty = true;
|
||||
const _connByModulePort = new Map(); // "moduleId-portName" → [connections]
|
||||
|
||||
export function invalidateConnectionCache() {
|
||||
_connCacheDirty = true;
|
||||
}
|
||||
|
||||
function getConnectionsFrom(moduleId, portName) {
|
||||
if (_connCacheDirty) {
|
||||
_connByModulePort.clear();
|
||||
for (const conn of state.connections) {
|
||||
const key = `${conn.from.moduleId}-${conn.from.port}`;
|
||||
if (!_connByModulePort.has(key)) _connByModulePort.set(key, []);
|
||||
_connByModulePort.get(key).push(conn);
|
||||
}
|
||||
_connCacheDirty = false;
|
||||
}
|
||||
return _connByModulePort.get(`${moduleId}-${portName}`) || [];
|
||||
}
|
||||
|
||||
export function setSequencerSignals(moduleId, freq, gate) {
|
||||
const entry = audioNodes[moduleId];
|
||||
if (!entry) return;
|
||||
if (entry._freqSig) entry._freqSig.value = freq;
|
||||
if (entry._gateSig) entry._gateSig.value = gate ? 1 : 0;
|
||||
|
||||
// Set connected oscillator frequencies directly
|
||||
for (const conn of getConnectionsFrom(moduleId, 'freq')) {
|
||||
const oscEntry = audioNodes[conn.to.moduleId];
|
||||
if (oscEntry?.node?.frequency) {
|
||||
oscEntry.node.frequency.value = freq;
|
||||
}
|
||||
}
|
||||
|
||||
// Trigger connected envelopes
|
||||
for (const conn of getConnectionsFrom(moduleId, 'gate')) {
|
||||
const envEntry = audioNodes[conn.to.moduleId];
|
||||
if (envEntry && envEntry.node instanceof Tone.Envelope) {
|
||||
if (gate) envEntry.node.triggerAttack();
|
||||
else envEntry.node.triggerRelease();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function triggerKeyboard(moduleId, freq, gate) {
|
||||
const entry = audioNodes[moduleId];
|
||||
if (!entry) return;
|
||||
if (entry._freqSig) entry._freqSig.value = freq;
|
||||
if (entry._gateSig) entry._gateSig.value = gate ? 1 : 0;
|
||||
|
||||
// Set connected oscillator frequencies directly
|
||||
for (const conn of getConnectionsFrom(moduleId, 'freq')) {
|
||||
const oscEntry = audioNodes[conn.to.moduleId];
|
||||
if (oscEntry?.node?.frequency) {
|
||||
oscEntry.node.frequency.value = freq;
|
||||
}
|
||||
}
|
||||
|
||||
// Trigger connected envelopes
|
||||
for (const conn of getConnectionsFrom(moduleId, 'gate')) {
|
||||
const envEntry = audioNodes[conn.to.moduleId];
|
||||
if (envEntry && envEntry.node instanceof Tone.Envelope) {
|
||||
if (gate) envEntry.node.triggerAttack();
|
||||
else envEntry.node.triggerRelease();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function startAudio() {
|
||||
await Tone.start();
|
||||
state.isRunning = true;
|
||||
startMasterClock();
|
||||
|
||||
// Rebuild entire audio graph
|
||||
rebuildGraph();
|
||||
}
|
||||
|
||||
export function stopAudio() {
|
||||
stopMasterClock();
|
||||
|
||||
// Stop and reset Transport
|
||||
try {
|
||||
Tone.getTransport().stop();
|
||||
Tone.getTransport().cancel();
|
||||
Tone.getTransport().position = 0;
|
||||
} catch (e) {}
|
||||
|
||||
// Destroy all nodes
|
||||
for (const id of Object.keys(audioNodes)) {
|
||||
destroyNode(parseInt(id));
|
||||
}
|
||||
state.isRunning = false;
|
||||
}
|
||||
|
||||
export function rebuildGraph() {
|
||||
// Destroy all existing nodes
|
||||
for (const id of Object.keys(audioNodes)) {
|
||||
destroyNode(parseInt(id));
|
||||
}
|
||||
|
||||
// Create nodes for all modules
|
||||
for (const mod of state.modules) {
|
||||
ensureNode(mod.id);
|
||||
}
|
||||
|
||||
// Create all connections
|
||||
for (const conn of state.connections) {
|
||||
connectWire(conn);
|
||||
}
|
||||
|
||||
// Zero base gain on VCAs with active CV connection.
|
||||
// When envelope controls VCA, base gain must be 0 so silence is possible.
|
||||
for (const mod of state.modules) {
|
||||
if (mod.type !== 'vca') continue;
|
||||
const hasCV = state.connections.some(c => c.to.moduleId === mod.id && c.to.port === 'cv');
|
||||
const entry = audioNodes[mod.id];
|
||||
if (entry && hasCV) entry.node.gain.value = 0;
|
||||
}
|
||||
|
||||
// Auto-trigger envelopes that have no gate connection (free-running mode).
|
||||
// This allows noise/ambient patches to work without a keyboard/sequencer.
|
||||
for (const mod of state.modules) {
|
||||
if (mod.type !== 'envelope') continue;
|
||||
const hasGateInput = state.connections.some(
|
||||
c => c.to.moduleId === mod.id && c.to.port === 'gate'
|
||||
);
|
||||
if (!hasGateInput) {
|
||||
const entry = audioNodes[mod.id];
|
||||
if (entry && entry.node && typeof entry.node.triggerAttack === 'function') {
|
||||
entry.node.triggerAttack();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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) {
|
||||
const entry = audioNodes[moduleId];
|
||||
if (!entry || !entry.analyser) return null;
|
||||
return entry.analyser.getValue();
|
||||
}
|
||||
328
packages/client/src/engine/moduleRegistry.js
Normal file
328
packages/client/src/engine/moduleRegistry.js
Normal file
@@ -0,0 +1,328 @@
|
||||
/**
|
||||
* moduleRegistry.js — Defines all available module types
|
||||
* Each module type specifies: ports, params, icon, category, and audio factory
|
||||
*/
|
||||
|
||||
export const PORT_TYPE = {
|
||||
AUDIO: 'audio',
|
||||
CONTROL: 'control',
|
||||
TRIGGER: 'trigger',
|
||||
};
|
||||
|
||||
// Module type definitions
|
||||
const registry = {};
|
||||
|
||||
export function defineModule(type, def) {
|
||||
registry[type] = { type, ...def };
|
||||
}
|
||||
|
||||
export function getModuleDef(type) {
|
||||
return registry[type] || null;
|
||||
}
|
||||
|
||||
export function getAllModuleDefs() {
|
||||
return Object.values(registry);
|
||||
}
|
||||
|
||||
export function getModulesByCategory() {
|
||||
const cats = {};
|
||||
for (const def of Object.values(registry)) {
|
||||
if (!cats[def.category]) cats[def.category] = [];
|
||||
cats[def.category].push(def);
|
||||
}
|
||||
return cats;
|
||||
}
|
||||
|
||||
// ==================== SOURCE ====================
|
||||
|
||||
defineModule('oscillator', {
|
||||
name: 'Oscillator',
|
||||
icon: '~',
|
||||
category: 'Source',
|
||||
inputs: [
|
||||
{ name: 'freq', type: PORT_TYPE.AUDIO, label: 'Freq' },
|
||||
{ name: 'detune', type: PORT_TYPE.CONTROL, label: 'Detune' },
|
||||
],
|
||||
outputs: [
|
||||
{ name: 'out', type: PORT_TYPE.AUDIO, label: 'Out' },
|
||||
],
|
||||
params: {
|
||||
waveform: { type: 'select', options: ['sine', 'square', 'sawtooth', 'triangle'], default: 'sawtooth', label: 'Wave' },
|
||||
frequency: { type: 'knob', min: 20, max: 8000, default: 440, unit: 'Hz', label: 'Freq' },
|
||||
detune: { type: 'knob', min: -1200, max: 1200, default: 0, unit: 'ct', label: 'Detune' },
|
||||
},
|
||||
});
|
||||
|
||||
defineModule('lfo', {
|
||||
name: 'LFO',
|
||||
icon: '∿',
|
||||
category: 'Source',
|
||||
inputs: [],
|
||||
outputs: [
|
||||
{ name: 'out', type: PORT_TYPE.CONTROL, label: 'Out' },
|
||||
],
|
||||
params: {
|
||||
waveform: { type: 'select', options: ['sine', 'square', 'sawtooth', 'triangle'], default: 'sine', label: 'Wave' },
|
||||
frequency: { type: 'knob', min: 0.01, max: 50, default: 2, unit: 'Hz', label: 'Rate' },
|
||||
amplitude: { type: 'knob', min: 0, max: 1, default: 0.5, unit: '', label: 'Depth' },
|
||||
},
|
||||
});
|
||||
|
||||
defineModule('noise', {
|
||||
name: 'Noise',
|
||||
icon: '⣿',
|
||||
category: 'Source',
|
||||
inputs: [],
|
||||
outputs: [
|
||||
{ name: 'out', type: PORT_TYPE.AUDIO, label: 'Out' },
|
||||
],
|
||||
params: {
|
||||
type: { type: 'select', options: ['white', 'pink', 'brown'], default: 'white', label: 'Type' },
|
||||
},
|
||||
});
|
||||
|
||||
// ==================== FILTER ====================
|
||||
|
||||
defineModule('filter', {
|
||||
name: 'Filter',
|
||||
icon: '▽',
|
||||
category: 'Filter',
|
||||
inputs: [
|
||||
{ name: 'in', type: PORT_TYPE.AUDIO, label: 'In' },
|
||||
{ name: 'cutoff', type: PORT_TYPE.CONTROL, label: 'Cutoff' },
|
||||
],
|
||||
outputs: [
|
||||
{ name: 'out', type: PORT_TYPE.AUDIO, label: 'Out' },
|
||||
],
|
||||
params: {
|
||||
type: { type: 'select', options: ['lowpass', 'highpass', 'bandpass', 'notch'], default: 'lowpass', label: 'Type' },
|
||||
frequency: { type: 'knob', min: 20, max: 20000, default: 1000, unit: 'Hz', label: 'Cutoff' },
|
||||
Q: { type: 'knob', min: 0.1, max: 20, default: 1, unit: '', label: 'Reso' },
|
||||
},
|
||||
});
|
||||
|
||||
// ==================== ENVELOPE ====================
|
||||
|
||||
defineModule('envelope', {
|
||||
name: 'Envelope',
|
||||
icon: '⏤╲',
|
||||
category: 'Modulation',
|
||||
inputs: [
|
||||
{ name: 'gate', type: PORT_TYPE.TRIGGER, label: 'Gate' },
|
||||
],
|
||||
outputs: [
|
||||
{ name: 'out', type: PORT_TYPE.CONTROL, label: 'Out' },
|
||||
],
|
||||
params: {
|
||||
attack: { type: 'knob', min: 0.001, max: 4, default: 0.01, unit: 's', label: 'Attack' },
|
||||
decay: { type: 'knob', min: 0.001, max: 4, default: 0.2, unit: 's', label: 'Decay' },
|
||||
sustain: { type: 'knob', min: 0, max: 1, default: 0.5, unit: '', label: 'Sustain' },
|
||||
release: { type: 'knob', min: 0.001, max: 8, default: 0.5, unit: 's', label: 'Release' },
|
||||
},
|
||||
});
|
||||
|
||||
// ==================== AMPLIFIER ====================
|
||||
|
||||
defineModule('vca', {
|
||||
name: 'VCA',
|
||||
icon: '△',
|
||||
category: 'Utility',
|
||||
inputs: [
|
||||
{ name: 'in', type: PORT_TYPE.AUDIO, label: 'In' },
|
||||
{ name: 'cv', type: PORT_TYPE.CONTROL, label: 'CV' },
|
||||
],
|
||||
outputs: [
|
||||
{ name: 'out', type: PORT_TYPE.AUDIO, label: 'Out' },
|
||||
],
|
||||
params: {
|
||||
gain: { type: 'knob', min: 0, max: 1, default: 0.8, unit: '', label: 'Gain' },
|
||||
},
|
||||
});
|
||||
|
||||
// ==================== EFFECTS ====================
|
||||
|
||||
defineModule('delay', {
|
||||
name: 'Delay',
|
||||
icon: '⟫',
|
||||
category: 'Effect',
|
||||
inputs: [
|
||||
{ name: 'in', type: PORT_TYPE.AUDIO, label: 'In' },
|
||||
],
|
||||
outputs: [
|
||||
{ name: 'out', type: PORT_TYPE.AUDIO, label: 'Out' },
|
||||
],
|
||||
params: {
|
||||
delayTime: { type: 'knob', min: 0.01, max: 2, default: 0.3, unit: 's', label: 'Time' },
|
||||
feedback: { type: 'knob', min: 0, max: 0.95, default: 0.4, unit: '', label: 'Feedbk' },
|
||||
wet: { type: 'knob', min: 0, max: 1, default: 0.5, unit: '', label: 'Mix' },
|
||||
},
|
||||
});
|
||||
|
||||
defineModule('reverb', {
|
||||
name: 'Reverb',
|
||||
icon: '◌',
|
||||
category: 'Effect',
|
||||
inputs: [
|
||||
{ name: 'in', type: PORT_TYPE.AUDIO, label: 'In' },
|
||||
],
|
||||
outputs: [
|
||||
{ name: 'out', type: PORT_TYPE.AUDIO, label: 'Out' },
|
||||
],
|
||||
params: {
|
||||
decay: { type: 'knob', min: 0.1, max: 15, default: 3, unit: 's', label: 'Decay' },
|
||||
wet: { type: 'knob', min: 0, max: 1, default: 0.4, unit: '', label: 'Mix' },
|
||||
},
|
||||
});
|
||||
|
||||
defineModule('distortion', {
|
||||
name: 'Distortion',
|
||||
icon: '⚡',
|
||||
category: 'Effect',
|
||||
inputs: [
|
||||
{ name: 'in', type: PORT_TYPE.AUDIO, label: 'In' },
|
||||
],
|
||||
outputs: [
|
||||
{ name: 'out', type: PORT_TYPE.AUDIO, label: 'Out' },
|
||||
],
|
||||
params: {
|
||||
distortion: { type: 'knob', min: 0, max: 1, default: 0.4, unit: '', label: 'Drive' },
|
||||
wet: { type: 'knob', min: 0, max: 1, default: 0.5, unit: '', label: 'Mix' },
|
||||
},
|
||||
});
|
||||
|
||||
// ==================== MIXER ====================
|
||||
|
||||
defineModule('mixer', {
|
||||
name: 'Mixer',
|
||||
icon: '≡',
|
||||
category: 'Utility',
|
||||
inputs: [
|
||||
{ name: 'in1', type: PORT_TYPE.AUDIO, label: 'In 1' },
|
||||
{ name: 'in2', type: PORT_TYPE.AUDIO, label: 'In 2' },
|
||||
{ name: 'in3', type: PORT_TYPE.AUDIO, label: 'In 3' },
|
||||
{ name: 'in4', type: PORT_TYPE.AUDIO, label: 'In 4' },
|
||||
],
|
||||
outputs: [
|
||||
{ name: 'out', type: PORT_TYPE.AUDIO, label: 'Out' },
|
||||
],
|
||||
params: {
|
||||
gain1: { type: 'knob', min: 0, max: 1, default: 0.8, unit: '', label: 'Ch 1' },
|
||||
gain2: { type: 'knob', min: 0, max: 1, default: 0.8, unit: '', label: 'Ch 2' },
|
||||
gain3: { type: 'knob', min: 0, max: 1, default: 0.8, unit: '', label: 'Ch 3' },
|
||||
gain4: { type: 'knob', min: 0, max: 1, default: 0.8, unit: '', label: 'Ch 4' },
|
||||
},
|
||||
});
|
||||
|
||||
// ==================== SCOPE ====================
|
||||
|
||||
defineModule('scope', {
|
||||
name: 'Scope',
|
||||
icon: '📊',
|
||||
category: 'Utility',
|
||||
inputs: [
|
||||
{ name: 'in', type: PORT_TYPE.AUDIO, label: 'In' },
|
||||
],
|
||||
outputs: [],
|
||||
params: {},
|
||||
});
|
||||
|
||||
// ==================== CV TO GATE ====================
|
||||
|
||||
defineModule('cv2gate', {
|
||||
name: 'CV→Gate',
|
||||
icon: '⚡',
|
||||
category: 'Utility',
|
||||
inputs: [
|
||||
{ name: 'in', type: PORT_TYPE.CONTROL, label: 'CV In' },
|
||||
],
|
||||
outputs: [
|
||||
{ name: 'gate', type: PORT_TYPE.TRIGGER, label: 'Gate' },
|
||||
],
|
||||
params: {
|
||||
threshold: { type: 'knob', min: 0, max: 1, default: 0.5, unit: '', label: 'Thresh' },
|
||||
},
|
||||
});
|
||||
|
||||
// ==================== OUTPUT ====================
|
||||
|
||||
defineModule('output', {
|
||||
name: 'Output',
|
||||
icon: '🔊',
|
||||
category: 'Output',
|
||||
inputs: [
|
||||
{ name: 'left', type: PORT_TYPE.AUDIO, label: 'Left' },
|
||||
{ name: 'right', type: PORT_TYPE.AUDIO, label: 'Right' },
|
||||
],
|
||||
outputs: [],
|
||||
params: {
|
||||
volume: { type: 'knob', min: -60, max: 6, default: -6, unit: 'dB', label: 'Volume' },
|
||||
},
|
||||
});
|
||||
|
||||
// ==================== KEYBOARD ====================
|
||||
|
||||
defineModule('keyboard', {
|
||||
name: 'Keyboard',
|
||||
icon: '🎹',
|
||||
category: 'Source',
|
||||
inputs: [],
|
||||
outputs: [
|
||||
{ name: 'freq', type: PORT_TYPE.AUDIO, label: 'Freq' },
|
||||
{ name: 'gate', type: PORT_TYPE.TRIGGER, label: 'Gate' },
|
||||
],
|
||||
params: {
|
||||
octave: { type: 'knob', min: 1, max: 8, default: 4, unit: '', label: 'Octave' },
|
||||
},
|
||||
});
|
||||
|
||||
// ==================== DRUM PAD ====================
|
||||
|
||||
defineModule('drumpad', {
|
||||
name: 'Drum Pad',
|
||||
icon: '🥁',
|
||||
category: 'Source',
|
||||
inputs: [],
|
||||
outputs: [
|
||||
{ name: 'freq', type: PORT_TYPE.AUDIO, label: 'Freq' },
|
||||
{ name: 'gate', type: PORT_TYPE.TRIGGER, label: 'Gate' },
|
||||
],
|
||||
params: {},
|
||||
});
|
||||
|
||||
// ==================== SEQUENCER ====================
|
||||
|
||||
defineModule('sequencer', {
|
||||
name: 'Sequencer',
|
||||
icon: '▦',
|
||||
category: 'Source',
|
||||
inputs: [],
|
||||
outputs: [
|
||||
{ name: 'freq', type: PORT_TYPE.AUDIO, label: 'Freq' },
|
||||
{ name: 'gate', type: PORT_TYPE.TRIGGER, label: 'Gate' },
|
||||
],
|
||||
params: {
|
||||
bpm: { type: 'knob', min: 40, max: 300, default: 140, unit: 'bpm', label: 'BPM' },
|
||||
steps: { type: 'select', options: ['8', '16', '32'], default: '16', label: 'Steps' },
|
||||
swing: { type: 'knob', min: 0, max: 0.5, default: 0, unit: '', label: 'Swing' },
|
||||
},
|
||||
// Custom data: step notes/gates stored in module.params._steps
|
||||
});
|
||||
|
||||
// ==================== PIANO ROLL ====================
|
||||
|
||||
defineModule('pianoroll', {
|
||||
name: 'Piano Roll',
|
||||
icon: '🎼',
|
||||
category: 'Source',
|
||||
inputs: [],
|
||||
outputs: [
|
||||
{ name: 'freq', type: PORT_TYPE.AUDIO, label: 'Freq' },
|
||||
{ name: 'gate', type: PORT_TYPE.TRIGGER, label: 'Gate' },
|
||||
],
|
||||
params: {
|
||||
bpm: { type: 'knob', min: 40, max: 300, default: 140, unit: 'bpm', label: 'BPM' },
|
||||
loop: { type: 'select', options: ['on', 'off'], default: 'on', label: 'Loop' },
|
||||
bars: { type: 'select', options: ['1', '2', '4', '8'], default: '4', label: 'Bars' },
|
||||
},
|
||||
// Custom data: notes stored in module.params._notes = [{note, start, duration}, ...]
|
||||
});
|
||||
83
packages/client/src/engine/presets.js
Normal file
83
packages/client/src/engine/presets.js
Normal file
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* presets.js — Save/load presets to localStorage
|
||||
*/
|
||||
import { serialize, deserialize } from './state.js';
|
||||
import { rebuildGraph } from './audioEngine.js';
|
||||
|
||||
const STORAGE_KEY = 'reaktor_presets';
|
||||
const AUTOSAVE_KEY = 'reaktor_autosave';
|
||||
|
||||
export function getPresets() {
|
||||
try {
|
||||
return JSON.parse(localStorage.getItem(STORAGE_KEY) || '[]');
|
||||
} catch { return []; }
|
||||
}
|
||||
|
||||
export function savePreset(name) {
|
||||
const presets = getPresets();
|
||||
const data = serialize();
|
||||
data.name = name;
|
||||
data.savedAt = new Date().toISOString();
|
||||
// Replace if same name exists
|
||||
const idx = presets.findIndex(p => p.name === name);
|
||||
if (idx >= 0) presets[idx] = data;
|
||||
else presets.unshift(data);
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(presets));
|
||||
}
|
||||
|
||||
export function loadPreset(name) {
|
||||
const presets = getPresets();
|
||||
const preset = presets.find(p => p.name === name);
|
||||
if (!preset) return false;
|
||||
deserialize(preset);
|
||||
rebuildGraph();
|
||||
return true;
|
||||
}
|
||||
|
||||
export function deletePreset(name) {
|
||||
const presets = getPresets().filter(p => p.name !== name);
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(presets));
|
||||
}
|
||||
|
||||
export function autoSave() {
|
||||
const data = serialize();
|
||||
data.savedAt = new Date().toISOString();
|
||||
localStorage.setItem(AUTOSAVE_KEY, JSON.stringify(data));
|
||||
}
|
||||
|
||||
export function autoLoad() {
|
||||
try {
|
||||
const raw = localStorage.getItem(AUTOSAVE_KEY);
|
||||
if (!raw) return false;
|
||||
const data = JSON.parse(raw);
|
||||
deserialize(data);
|
||||
return true;
|
||||
} catch { return false; }
|
||||
}
|
||||
|
||||
export function exportPatch() {
|
||||
const data = serialize();
|
||||
const json = JSON.stringify(data, null, 2);
|
||||
const blob = new Blob([json], { type: 'application/json' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = 'patch.json';
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
export function importPatch(file) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
try {
|
||||
const data = JSON.parse(e.target.result);
|
||||
deserialize(data);
|
||||
rebuildGraph();
|
||||
resolve(true);
|
||||
} catch (err) { reject(err); }
|
||||
};
|
||||
reader.readAsText(file);
|
||||
});
|
||||
}
|
||||
145
packages/client/src/engine/state.js
Normal file
145
packages/client/src/engine/state.js
Normal file
@@ -0,0 +1,145 @@
|
||||
/**
|
||||
* state.js — Centralized reactive state for the modular synth
|
||||
* Uses a simple pub/sub pattern for React integration
|
||||
*/
|
||||
import { playConnect, playDisconnect, playModuleAdd, playModuleDelete } from './uiSounds.js';
|
||||
import { getModuleDef } from './moduleRegistry.js';
|
||||
import { invalidateConnectionCache } from './audioEngine.js';
|
||||
|
||||
let _listeners = new Set();
|
||||
let _nextModuleId = 1;
|
||||
let _nextConnectionId = 1;
|
||||
|
||||
export const state = {
|
||||
modules: [], // { id, type, x, y, params, collapsed }
|
||||
connections: [], // { id, from: {moduleId, port}, to: {moduleId, port} }
|
||||
|
||||
// Interaction
|
||||
selectedModuleId: null,
|
||||
dragging: null, // { moduleId, offsetX, offsetY }
|
||||
connecting: null, // { moduleId, port, portType, direction, x, y } (temp wire)
|
||||
|
||||
// Camera
|
||||
camX: 0, camY: 0, zoom: 1,
|
||||
panning: false, panStart: null,
|
||||
|
||||
// Audio
|
||||
isRunning: false,
|
||||
masterVolume: -6,
|
||||
|
||||
// UI
|
||||
showPalette: true,
|
||||
presetModal: null, // null | 'save' | 'load'
|
||||
};
|
||||
|
||||
export function subscribe(fn) {
|
||||
_listeners.add(fn);
|
||||
return () => _listeners.delete(fn);
|
||||
}
|
||||
|
||||
export function emit() {
|
||||
_listeners.forEach(fn => fn());
|
||||
}
|
||||
|
||||
export function addModule(type, x, y) {
|
||||
const id = _nextModuleId++;
|
||||
// Populate ALL default params so level checkers can read them immediately
|
||||
const def = getModuleDef(type);
|
||||
const defaults = def
|
||||
? Object.fromEntries(Object.entries(def.params).map(([k, v]) => [k, v.default]))
|
||||
: {};
|
||||
state.modules.push({ id, type, x, y, params: defaults, collapsed: false });
|
||||
state.selectedModuleId = id;
|
||||
emit();
|
||||
playModuleAdd();
|
||||
return id;
|
||||
}
|
||||
|
||||
export function removeModule(id) {
|
||||
state.modules = state.modules.filter(m => m.id !== id);
|
||||
state.connections = state.connections.filter(
|
||||
c => c.from.moduleId !== id && c.to.moduleId !== id
|
||||
);
|
||||
if (state.selectedModuleId === id) state.selectedModuleId = null;
|
||||
emit();
|
||||
playModuleDelete();
|
||||
}
|
||||
|
||||
export function updateModulePosition(id, x, y) {
|
||||
const m = state.modules.find(m => m.id === id);
|
||||
if (m) { m.x = x; m.y = y; emit(); }
|
||||
}
|
||||
|
||||
export function updateModuleParam(id, paramName, value) {
|
||||
const m = state.modules.find(m => m.id === id);
|
||||
if (m) { m.params[paramName] = value; emit(); }
|
||||
}
|
||||
|
||||
export function addConnection(fromModuleId, fromPort, toModuleId, toPort) {
|
||||
// Prevent duplicates
|
||||
const exists = state.connections.find(c =>
|
||||
c.from.moduleId === fromModuleId && c.from.port === fromPort &&
|
||||
c.to.moduleId === toModuleId && c.to.port === toPort
|
||||
);
|
||||
if (exists) return null;
|
||||
|
||||
// Prevent connecting to already-connected input
|
||||
const inputTaken = state.connections.find(c =>
|
||||
c.to.moduleId === toModuleId && c.to.port === toPort
|
||||
);
|
||||
if (inputTaken) {
|
||||
// Remove old connection to this input (silent — connect sound will play)
|
||||
removeConnection(inputTaken.id, true);
|
||||
}
|
||||
|
||||
const id = _nextConnectionId++;
|
||||
state.connections.push({ id, from: { moduleId: fromModuleId, port: fromPort }, to: { moduleId: toModuleId, port: toPort } });
|
||||
invalidateConnectionCache();
|
||||
emit();
|
||||
playConnect();
|
||||
return id;
|
||||
}
|
||||
|
||||
export function removeConnection(id, _silent = false) {
|
||||
state.connections = state.connections.filter(c => c.id !== id);
|
||||
invalidateConnectionCache();
|
||||
emit();
|
||||
if (!_silent) playDisconnect();
|
||||
}
|
||||
|
||||
export function getModule(id) {
|
||||
return state.modules.find(m => m.id === id) || null;
|
||||
}
|
||||
|
||||
export function isPortConnected(moduleId, portName, direction) {
|
||||
return state.connections.some(c =>
|
||||
direction === 'output'
|
||||
? (c.from.moduleId === moduleId && c.from.port === portName)
|
||||
: (c.to.moduleId === moduleId && c.to.port === portName)
|
||||
);
|
||||
}
|
||||
|
||||
// Serialization
|
||||
export function serialize() {
|
||||
return {
|
||||
modules: state.modules.map(m => ({ id: m.id, type: m.type, x: m.x, y: m.y, params: { ...m.params } })),
|
||||
connections: state.connections.map(c => ({ ...c })),
|
||||
camera: { camX: state.camX, camY: state.camY, zoom: state.zoom },
|
||||
masterVolume: state.masterVolume,
|
||||
};
|
||||
}
|
||||
|
||||
export function deserialize(data) {
|
||||
state.modules = data.modules || [];
|
||||
state.connections = data.connections || [];
|
||||
if (data.camera) {
|
||||
state.camX = data.camera.camX || 0;
|
||||
state.camY = data.camera.camY || 0;
|
||||
state.zoom = data.camera.zoom || 1;
|
||||
}
|
||||
state.masterVolume = data.masterVolume ?? -6;
|
||||
_nextModuleId = Math.max(1, ...state.modules.map(m => m.id)) + 1;
|
||||
_nextConnectionId = Math.max(1, ...state.connections.map(c => c.id)) + 1;
|
||||
state.selectedModuleId = null;
|
||||
emit();
|
||||
}
|
||||
223
packages/client/src/engine/uiSounds.js
Normal file
223
packages/client/src/engine/uiSounds.js
Normal file
@@ -0,0 +1,223 @@
|
||||
/**
|
||||
* uiSounds.js — Procedural UI sound effects using Tone.js
|
||||
* All sounds are synthesized on-the-fly — no audio files needed.
|
||||
* Sounds are short, subtle, and "synth-themed" to match the app.
|
||||
*/
|
||||
import * as Tone from 'tone';
|
||||
|
||||
let _enabled = true;
|
||||
let _volume = -18; // dB, subtle
|
||||
let _initialized = false;
|
||||
let _masterGain = null;
|
||||
|
||||
// Lazy init — only create audio nodes after user interaction (Tone.start)
|
||||
function ensureInit() {
|
||||
if (_initialized) return true;
|
||||
if (Tone.context.state !== 'running') return false;
|
||||
_masterGain = new Tone.Gain(Tone.dbToGain(_volume)).toDestination();
|
||||
_initialized = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
export function setUISoundsEnabled(enabled) { _enabled = enabled; }
|
||||
export function isUISoundsEnabled() { return _enabled; }
|
||||
export function setUIVolume(db) {
|
||||
_volume = db;
|
||||
if (_masterGain) _masterGain.gain.value = Tone.dbToGain(db);
|
||||
}
|
||||
|
||||
// ==================== Sound definitions ====================
|
||||
|
||||
/** Cable connected — short bright "click" with rising pitch */
|
||||
export function playConnect() {
|
||||
if (!_enabled || !ensureInit()) return;
|
||||
const synth = new Tone.Synth({
|
||||
oscillator: { type: 'sine' },
|
||||
envelope: { attack: 0.001, decay: 0.08, sustain: 0, release: 0.05 },
|
||||
}).connect(_masterGain);
|
||||
synth.triggerAttackRelease('C6', 0.06);
|
||||
setTimeout(() => {
|
||||
const synth2 = new Tone.Synth({
|
||||
oscillator: { type: 'sine' },
|
||||
envelope: { attack: 0.001, decay: 0.06, sustain: 0, release: 0.04 },
|
||||
}).connect(_masterGain);
|
||||
synth2.triggerAttackRelease('E6', 0.05);
|
||||
setTimeout(() => synth2.dispose(), 200);
|
||||
}, 40);
|
||||
setTimeout(() => synth.dispose(), 300);
|
||||
}
|
||||
|
||||
/** Cable disconnected — short descending blip */
|
||||
export function playDisconnect() {
|
||||
if (!_enabled || !ensureInit()) return;
|
||||
const synth = new Tone.Synth({
|
||||
oscillator: { type: 'triangle' },
|
||||
envelope: { attack: 0.001, decay: 0.1, sustain: 0, release: 0.05 },
|
||||
}).connect(_masterGain);
|
||||
synth.triggerAttackRelease('E5', 0.06);
|
||||
setTimeout(() => {
|
||||
const synth2 = new Tone.Synth({
|
||||
oscillator: { type: 'triangle' },
|
||||
envelope: { attack: 0.001, decay: 0.08, sustain: 0, release: 0.04 },
|
||||
}).connect(_masterGain);
|
||||
synth2.triggerAttackRelease('C5', 0.05);
|
||||
setTimeout(() => synth2.dispose(), 200);
|
||||
}, 50);
|
||||
setTimeout(() => synth.dispose(), 300);
|
||||
}
|
||||
|
||||
/** Module added — soft metallic "pop" */
|
||||
export function playModuleAdd() {
|
||||
if (!_enabled || !ensureInit()) return;
|
||||
const synth = new Tone.MembraneSynth({
|
||||
pitchDecay: 0.01,
|
||||
octaves: 4,
|
||||
envelope: { attack: 0.001, decay: 0.15, sustain: 0, release: 0.1 },
|
||||
}).connect(_masterGain);
|
||||
synth.triggerAttackRelease('C4', 0.08);
|
||||
setTimeout(() => synth.dispose(), 400);
|
||||
}
|
||||
|
||||
/** Module deleted — reverse "zap" */
|
||||
export function playModuleDelete() {
|
||||
if (!_enabled || !ensureInit()) return;
|
||||
const synth = new Tone.Synth({
|
||||
oscillator: { type: 'sawtooth' },
|
||||
envelope: { attack: 0.001, decay: 0.12, sustain: 0, release: 0.05 },
|
||||
}).connect(_masterGain);
|
||||
synth.triggerAttackRelease('A3', 0.08);
|
||||
setTimeout(() => synth.dispose(), 300);
|
||||
}
|
||||
|
||||
/** Button click — tiny tick */
|
||||
export function playClick() {
|
||||
if (!_enabled || !ensureInit()) return;
|
||||
const synth = new Tone.Synth({
|
||||
oscillator: { type: 'sine' },
|
||||
envelope: { attack: 0.001, decay: 0.03, sustain: 0, release: 0.02 },
|
||||
}).connect(_masterGain);
|
||||
synth.triggerAttackRelease('A5', 0.02);
|
||||
setTimeout(() => synth.dispose(), 150);
|
||||
}
|
||||
|
||||
/** Star earned — bright ascending arpeggio */
|
||||
export function playStar(starNumber = 1) {
|
||||
if (!_enabled || !ensureInit()) return;
|
||||
const notes = ['C5', 'E5', 'G5'];
|
||||
const note = notes[Math.min(starNumber - 1, 2)];
|
||||
const delay = (starNumber - 1) * 300;
|
||||
setTimeout(() => {
|
||||
const synth = new Tone.Synth({
|
||||
oscillator: { type: 'sine' },
|
||||
envelope: { attack: 0.005, decay: 0.3, sustain: 0.1, release: 0.3 },
|
||||
}).connect(_masterGain);
|
||||
synth.triggerAttackRelease(note, 0.25);
|
||||
// Shimmer harmonic
|
||||
const shimmer = new Tone.Synth({
|
||||
oscillator: { type: 'sine' },
|
||||
envelope: { attack: 0.01, decay: 0.2, sustain: 0, release: 0.2 },
|
||||
volume: -6,
|
||||
}).connect(_masterGain);
|
||||
shimmer.triggerAttackRelease(
|
||||
Tone.Frequency(note).transpose(12).toNote(), 0.15
|
||||
);
|
||||
setTimeout(() => { synth.dispose(); shimmer.dispose(); }, 800);
|
||||
}, delay);
|
||||
}
|
||||
|
||||
/** Level complete — triumphant chord */
|
||||
export function playLevelComplete() {
|
||||
if (!_enabled || !ensureInit()) return;
|
||||
const chord = ['C4', 'E4', 'G4', 'C5'];
|
||||
chord.forEach((note, i) => {
|
||||
setTimeout(() => {
|
||||
const synth = new Tone.Synth({
|
||||
oscillator: { type: 'triangle' },
|
||||
envelope: { attack: 0.01, decay: 0.5, sustain: 0.2, release: 0.5 },
|
||||
}).connect(_masterGain);
|
||||
synth.triggerAttackRelease(note, 0.4);
|
||||
setTimeout(() => synth.dispose(), 1200);
|
||||
}, i * 60);
|
||||
});
|
||||
}
|
||||
|
||||
/** Level failed / check failed — low "bonk" */
|
||||
export function playFail() {
|
||||
if (!_enabled || !ensureInit()) return;
|
||||
const synth = new Tone.Synth({
|
||||
oscillator: { type: 'square' },
|
||||
envelope: { attack: 0.001, decay: 0.15, sustain: 0, release: 0.1 },
|
||||
}).connect(_masterGain);
|
||||
synth.triggerAttackRelease('D#3', 0.1);
|
||||
setTimeout(() => {
|
||||
const synth2 = new Tone.Synth({
|
||||
oscillator: { type: 'square' },
|
||||
envelope: { attack: 0.001, decay: 0.2, sustain: 0, release: 0.1 },
|
||||
}).connect(_masterGain);
|
||||
synth2.triggerAttackRelease('C3', 0.12);
|
||||
setTimeout(() => synth2.dispose(), 400);
|
||||
}, 100);
|
||||
setTimeout(() => synth.dispose(), 400);
|
||||
}
|
||||
|
||||
/** Hint revealed — mysterious "whoosh" */
|
||||
export function playHint() {
|
||||
if (!_enabled || !ensureInit()) return;
|
||||
const noise = new Tone.Noise('pink');
|
||||
const filter = new Tone.Filter({ type: 'bandpass', frequency: 2000, Q: 2 });
|
||||
const env = new Tone.AmplitudeEnvelope({ attack: 0.05, decay: 0.2, sustain: 0, release: 0.1 });
|
||||
noise.connect(filter).connect(env).connect(_masterGain);
|
||||
noise.start();
|
||||
env.triggerAttack();
|
||||
setTimeout(() => { env.triggerRelease(); }, 150);
|
||||
setTimeout(() => { noise.stop(); noise.dispose(); filter.dispose(); env.dispose(); }, 600);
|
||||
}
|
||||
|
||||
/** Audio engine start — power-on sweep */
|
||||
export function playEngineStart() {
|
||||
if (!_enabled || !ensureInit()) return;
|
||||
const synth = new Tone.Synth({
|
||||
oscillator: { type: 'sine' },
|
||||
envelope: { attack: 0.1, decay: 0.2, sustain: 0, release: 0.1 },
|
||||
}).connect(_masterGain);
|
||||
synth.triggerAttackRelease('C4', 0.15);
|
||||
setTimeout(() => {
|
||||
const synth2 = new Tone.Synth({
|
||||
oscillator: { type: 'sine' },
|
||||
envelope: { attack: 0.05, decay: 0.15, sustain: 0, release: 0.1 },
|
||||
}).connect(_masterGain);
|
||||
synth2.triggerAttackRelease('G4', 0.12);
|
||||
setTimeout(() => synth2.dispose(), 400);
|
||||
}, 100);
|
||||
setTimeout(() => synth.dispose(), 400);
|
||||
}
|
||||
|
||||
/** Audio engine stop — power-down */
|
||||
export function playEngineStop() {
|
||||
if (!_enabled || !ensureInit()) return;
|
||||
const synth = new Tone.Synth({
|
||||
oscillator: { type: 'sine' },
|
||||
envelope: { attack: 0.01, decay: 0.2, sustain: 0, release: 0.1 },
|
||||
}).connect(_masterGain);
|
||||
synth.triggerAttackRelease('G4', 0.1);
|
||||
setTimeout(() => {
|
||||
const synth2 = new Tone.Synth({
|
||||
oscillator: { type: 'sine' },
|
||||
envelope: { attack: 0.01, decay: 0.25, sustain: 0, release: 0.15 },
|
||||
}).connect(_masterGain);
|
||||
synth2.triggerAttackRelease('C4', 0.15);
|
||||
setTimeout(() => synth2.dispose(), 500);
|
||||
}, 80);
|
||||
setTimeout(() => synth.dispose(), 500);
|
||||
}
|
||||
|
||||
/** Navigation click (map, back buttons) — soft "tick" */
|
||||
export function playNav() {
|
||||
if (!_enabled || !ensureInit()) return;
|
||||
const synth = new Tone.Synth({
|
||||
oscillator: { type: 'sine' },
|
||||
envelope: { attack: 0.001, decay: 0.04, sustain: 0, release: 0.03 },
|
||||
}).connect(_masterGain);
|
||||
synth.triggerAttackRelease('E5', 0.03);
|
||||
setTimeout(() => synth.dispose(), 150);
|
||||
}
|
||||
124
packages/client/src/game/AdminPanel.jsx
Normal file
124
packages/client/src/game/AdminPanel.jsx
Normal file
@@ -0,0 +1,124 @@
|
||||
/**
|
||||
* AdminPanel.jsx — Debug/admin panel for SynthQuest
|
||||
* Allows adding/removing stars and unlocking levels for testing
|
||||
*/
|
||||
import React, { useState } from 'react';
|
||||
import { loadProgress, saveProgress, resetProgress } from './gameState.js';
|
||||
|
||||
export default function AdminPanel({ worlds, onClose, adminMode, onToggleAdmin }) {
|
||||
const [, refresh] = useState(0);
|
||||
const p = loadProgress();
|
||||
const totalStars = Object.values(p.completedLevels).reduce((s, l) => s + (l.stars || 0), 0);
|
||||
|
||||
const setStars = (levelId, stars) => {
|
||||
if (stars <= 0) {
|
||||
delete p.completedLevels[levelId];
|
||||
} else {
|
||||
p.completedLevels[levelId] = { stars: Math.min(3, stars), completedAt: Date.now() };
|
||||
}
|
||||
saveProgress();
|
||||
refresh(n => n + 1);
|
||||
};
|
||||
|
||||
const unlockWorld = (world) => {
|
||||
// Give 1 star to each level in all previous worlds up to the requirement
|
||||
let needed = world.unlockStars || 0;
|
||||
for (const w of worlds) {
|
||||
if (w.id === world.id) break;
|
||||
for (const level of w.levels) {
|
||||
if (needed <= 0) break;
|
||||
const existing = p.completedLevels[level.id]?.stars || 0;
|
||||
if (existing < 1) {
|
||||
p.completedLevels[level.id] = { stars: 1, completedAt: Date.now() };
|
||||
needed -= 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
saveProgress();
|
||||
refresh(n => n + 1);
|
||||
};
|
||||
|
||||
const giveAllStars = () => {
|
||||
for (const w of worlds) {
|
||||
for (const level of w.levels) {
|
||||
p.completedLevels[level.id] = { stars: 3, completedAt: Date.now() };
|
||||
}
|
||||
}
|
||||
saveProgress();
|
||||
refresh(n => n + 1);
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
resetProgress();
|
||||
refresh(n => n + 1);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="admin-overlay" onClick={onClose}>
|
||||
<div className="admin-panel" onClick={e => e.stopPropagation()}>
|
||||
<div className="admin-header">
|
||||
<h2>🛠 Admin Mode</h2>
|
||||
<span className="admin-total">Total: ★ {totalStars}</span>
|
||||
<button className="admin-close" onClick={onClose}>✕</button>
|
||||
</div>
|
||||
|
||||
<div className="admin-actions">
|
||||
<button
|
||||
className={`admin-action-btn ${adminMode ? 'active' : ''}`}
|
||||
onClick={onToggleAdmin}
|
||||
>
|
||||
{adminMode ? '🛠 Admin ON' : '🛠 Admin OFF'}
|
||||
</button>
|
||||
<button className="admin-action-btn gold" onClick={giveAllStars}>★★★ Todo</button>
|
||||
<button className="admin-action-btn danger" onClick={handleReset}>Reset Progreso</button>
|
||||
</div>
|
||||
|
||||
<div className="admin-worlds">
|
||||
{worlds.map((world, wi) => {
|
||||
const worldStars = world.levels.reduce((s, l) => {
|
||||
return s + (p.completedLevels[l.id]?.stars || 0);
|
||||
}, 0);
|
||||
const isUnlocked = !world.unlockStars || totalStars >= world.unlockStars;
|
||||
|
||||
return (
|
||||
<div key={world.id} className="admin-world">
|
||||
<div className="admin-world-header">
|
||||
<span className="admin-world-icon" style={{ color: world.color }}>{world.icon}</span>
|
||||
<span className="admin-world-name">M{wi + 1}: {world.name}</span>
|
||||
<span className="admin-world-stars">★ {worldStars}/{world.levels.length * 3}</span>
|
||||
{!isUnlocked && (
|
||||
<button className="admin-unlock-btn" onClick={() => unlockWorld(world)}>
|
||||
🔓 Desbloquear
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="admin-levels">
|
||||
{world.levels.map((level, li) => {
|
||||
const stars = p.completedLevels[level.id]?.stars || 0;
|
||||
return (
|
||||
<div key={level.id} className="admin-level">
|
||||
<span className="admin-level-num">{wi + 1}.{li + 1}</span>
|
||||
<span className="admin-level-name">{level.title}</span>
|
||||
<div className="admin-star-btns">
|
||||
{[0, 1, 2, 3].map(s => (
|
||||
<button
|
||||
key={s}
|
||||
className={`admin-star-btn ${stars >= s && s > 0 ? 'active' : ''} ${s === 0 ? 'zero' : ''}`}
|
||||
onClick={() => setStars(level.id, s)}
|
||||
>
|
||||
{s === 0 ? '✕' : '★'.repeat(s)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
93
packages/client/src/game/GameApp.jsx
Normal file
93
packages/client/src/game/GameApp.jsx
Normal file
@@ -0,0 +1,93 @@
|
||||
import React, { useState, useCallback } from 'react';
|
||||
import WorldMap from './WorldMap.jsx';
|
||||
import PuzzleView from './PuzzleView.jsx';
|
||||
import AdminPanel from './AdminPanel.jsx';
|
||||
import { WORLD_1 } from './levels/world1.js';
|
||||
import { WORLD_2 } from './levels/world2.js';
|
||||
import { WORLD_3 } from './levels/world3.js';
|
||||
import { WORLD_4 } from './levels/world4.js';
|
||||
import { WORLD_5 } from './levels/world5.js';
|
||||
import { WORLD_6 } from './levels/world6.js';
|
||||
import { WORLD_7 } from './levels/world7.js';
|
||||
import { WORLD_8 } from './levels/world8.js';
|
||||
import { WORLD_9 } from './levels/world9.js';
|
||||
import { WORLD_10 } from './levels/world10.js';
|
||||
import { WORLD_11 } from './levels/world11.js';
|
||||
import { WORLD_12 } from './levels/world12.js';
|
||||
|
||||
const allWorlds = [WORLD_1, WORLD_2, WORLD_3, WORLD_4, WORLD_5, WORLD_6, WORLD_7, WORLD_8, WORLD_9, WORLD_10, WORLD_11, WORLD_12];
|
||||
|
||||
export default function GameApp({ onSwitchToSandbox }) {
|
||||
const [view, setView] = useState('map');
|
||||
const [currentLevel, setCurrentLevel] = useState(null);
|
||||
const [currentLevelIndex, setCurrentLevelIndex] = useState(0);
|
||||
const [currentWorld, setCurrentWorld] = useState(null);
|
||||
const [showAdmin, setShowAdmin] = useState(false);
|
||||
const [adminMode, setAdminMode] = useState(false);
|
||||
|
||||
const handleSelectLevel = useCallback((level, world) => {
|
||||
const idx = world.levels.findIndex(l => l.id === level.id);
|
||||
setCurrentLevel(level);
|
||||
setCurrentLevelIndex(idx);
|
||||
setCurrentWorld(world);
|
||||
setView('puzzle');
|
||||
}, []);
|
||||
|
||||
const handleBack = useCallback(() => {
|
||||
setView('map');
|
||||
setCurrentLevel(null);
|
||||
setCurrentWorld(null);
|
||||
}, []);
|
||||
|
||||
const handleNextLevel = useCallback(() => {
|
||||
if (!currentWorld) return;
|
||||
const nextIdx = currentLevelIndex + 1;
|
||||
if (nextIdx < currentWorld.levels.length) {
|
||||
setCurrentLevel(currentWorld.levels[nextIdx]);
|
||||
setCurrentLevelIndex(nextIdx);
|
||||
} else {
|
||||
// Move to next world's first level if unlocked
|
||||
const worldIdx = allWorlds.findIndex(w => w.id === currentWorld.id);
|
||||
if (worldIdx < allWorlds.length - 1) {
|
||||
const nextWorld = allWorlds[worldIdx + 1];
|
||||
setCurrentWorld(nextWorld);
|
||||
setCurrentLevel(nextWorld.levels[0]);
|
||||
setCurrentLevelIndex(0);
|
||||
} else {
|
||||
setView('map');
|
||||
}
|
||||
}
|
||||
}, [currentLevelIndex, currentWorld]);
|
||||
|
||||
if (view === 'puzzle' && currentLevel && currentWorld) {
|
||||
return (
|
||||
<PuzzleView
|
||||
key={currentLevel.id}
|
||||
level={currentLevel}
|
||||
levelIndex={currentLevelIndex}
|
||||
worldLevels={currentWorld.levels}
|
||||
onBack={handleBack}
|
||||
onNextLevel={handleNextLevel}
|
||||
adminMode={adminMode}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<WorldMap
|
||||
onSelectLevel={handleSelectLevel}
|
||||
onSandbox={onSwitchToSandbox}
|
||||
onAdmin={() => setShowAdmin(true)}
|
||||
/>
|
||||
{showAdmin && (
|
||||
<AdminPanel
|
||||
worlds={allWorlds}
|
||||
onClose={() => setShowAdmin(false)}
|
||||
adminMode={adminMode}
|
||||
onToggleAdmin={() => setAdminMode(a => !a)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
74
packages/client/src/game/LevelComplete.jsx
Normal file
74
packages/client/src/game/LevelComplete.jsx
Normal file
@@ -0,0 +1,74 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { playStar, playNav } from '../engine/uiSounds.js';
|
||||
|
||||
export default function LevelComplete({ stars, checks, levelTitle, onNext, onRetry, onMap, isLastLevel, hintPenalty }) {
|
||||
const [showStars, setShowStars] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
const timers = [];
|
||||
for (let i = 1; i <= stars; i++) {
|
||||
timers.push(setTimeout(() => {
|
||||
setShowStars(i);
|
||||
playStar(i);
|
||||
}, i * 400));
|
||||
}
|
||||
return () => timers.forEach(clearTimeout);
|
||||
}, [stars]);
|
||||
|
||||
const messages = [
|
||||
'',
|
||||
'Has dado el primer paso...',
|
||||
hintPenalty ? 'Pista usada — tercera estrella bloqueada permanentemente.' : 'Buen trabajo — casi perfecto.',
|
||||
'Ejecucion impecable.',
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="gm-complete-overlay">
|
||||
<div className="gm-complete-card">
|
||||
<h2 className="gm-complete-title">
|
||||
{stars >= 1 ? 'Nivel Completado' : 'Sigue Intentando'}
|
||||
</h2>
|
||||
<p className="gm-complete-level">{levelTitle}</p>
|
||||
|
||||
<div className="gm-complete-stars">
|
||||
{[1, 2, 3].map(i => (
|
||||
<span
|
||||
key={i}
|
||||
className={`gm-big-star ${i <= showStars ? 'earned' : 'empty'} ${i === 3 && hintPenalty ? 'locked' : ''}`}
|
||||
>
|
||||
{i === 3 && hintPenalty ? '🔒' : '★'}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<p className="gm-complete-msg">{messages[stars] || ''}</p>
|
||||
|
||||
{hintPenalty && (
|
||||
<p className="gm-hint-penalty-msg">
|
||||
Usaste la pista — tercera estrella bloqueada
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="gm-checks">
|
||||
{checks.map((check, i) => (
|
||||
<div key={i} className={`gm-check ${check.passed ? 'passed' : 'failed'}`}>
|
||||
<span className="gm-check-icon">{check.passed ? '✓' : '✗'}</span>
|
||||
<span className="gm-check-name">{check.name}</span>
|
||||
<span className="gm-check-star">{'★'.repeat(check.star)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="gm-complete-actions">
|
||||
<button className="gm-btn secondary" onClick={() => { playNav(); onMap(); }}>Mapa</button>
|
||||
<button className="gm-btn secondary" onClick={onRetry}>
|
||||
Reintentar
|
||||
</button>
|
||||
{stars >= 1 && !isLastLevel && (
|
||||
<button className="gm-btn primary" onClick={onNext}>Siguiente →</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
625
packages/client/src/game/PuzzleView.jsx
Normal file
625
packages/client/src/game/PuzzleView.jsx
Normal file
@@ -0,0 +1,625 @@
|
||||
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { state, subscribe, addModule, emit, addConnection, removeModule, updateModulePosition, deserialize } from '../engine/state.js';
|
||||
import { startAudio, stopAudio, connectWire, rebuildGraph } from '../engine/audioEngine.js';
|
||||
import { getModuleDef } from '../engine/moduleRegistry.js';
|
||||
import ModuleNode from '../components/ModuleNode.jsx';
|
||||
import WireLayer from '../components/WireLayer.jsx';
|
||||
import BottomSheet from '../components/BottomSheet.jsx';
|
||||
import { useIsMobile } from '../hooks/useIsMobile.js';
|
||||
import { usePinchZoom } from '../hooks/usePinchZoom.js';
|
||||
import { playTarget, stopTarget, isTargetPlaying } from './targetAudio.js';
|
||||
import LevelComplete from './LevelComplete.jsx';
|
||||
import { completeLevel, saveLevelPatch, getLevelPatch, clearLevelPatch, markHintUsed, wasHintUsed } from './gameState.js';
|
||||
import { playLevelComplete, playFail, playHint, playEngineStart, playEngineStop, playNav } from '../engine/uiSounds.js';
|
||||
import { SOLUTIONS } from './autoSolver.js';
|
||||
|
||||
export default function PuzzleView({ level, levelIndex, worldLevels, onBack, onNextLevel, adminMode }) {
|
||||
const [, forceUpdate] = useState(0);
|
||||
const containerRef = useRef(null);
|
||||
const portPositions = useRef({});
|
||||
const [tempWire, setTempWire] = useState(null);
|
||||
const connectingRef = useRef(null);
|
||||
const [hintUsed, setHintUsed] = useState(false);
|
||||
const [showHint, setShowHint] = useState(false);
|
||||
const [result, setResult] = useState(null);
|
||||
const [targetPlaying, setTargetPlaying] = useState(false);
|
||||
const isMobile = useIsMobile();
|
||||
const [mobileTab, setMobileTab] = useState('mission');
|
||||
|
||||
// Pinch-to-zoom on mobile
|
||||
const getZoom = useCallback(() => state.zoom, []);
|
||||
const setZoom = useCallback((z) => { state.zoom = z; emit(); }, []);
|
||||
usePinchZoom(containerRef, getZoom, setZoom);
|
||||
|
||||
useEffect(() => {
|
||||
const unsub = subscribe(() => {
|
||||
forceUpdate(n => n + 1);
|
||||
// Auto-save patch on every state change (debounced below)
|
||||
scheduleSave();
|
||||
});
|
||||
return unsub;
|
||||
}, [level.id]);
|
||||
|
||||
// Debounced auto-save of the current patch
|
||||
const saveTimerRef = useRef(null);
|
||||
const scheduleSave = useCallback(() => {
|
||||
if (saveTimerRef.current) clearTimeout(saveTimerRef.current);
|
||||
saveTimerRef.current = setTimeout(() => {
|
||||
if (state.modules.length > 0) {
|
||||
saveLevelPatch(level.id, state.modules, state.connections);
|
||||
}
|
||||
}, 1000);
|
||||
}, [level.id]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (saveTimerRef.current) clearTimeout(saveTimerRef.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
loadLevel();
|
||||
// Center view on modules after level loads and DOM settles
|
||||
const timer = setTimeout(() => handleCenterView(), 100);
|
||||
return () => {
|
||||
clearTimeout(timer);
|
||||
stopAudio();
|
||||
stopTarget();
|
||||
};
|
||||
}, [level.id]);
|
||||
|
||||
const loadLevel = useCallback((forceReset = false) => {
|
||||
// Check for a saved patch first (unless explicitly resetting)
|
||||
const saved = !forceReset ? getLevelPatch(level.id) : null;
|
||||
if (saved) {
|
||||
const data = {
|
||||
modules: saved.modules.map(m => ({
|
||||
id: m.id, type: m.type, x: m.x, y: m.y, params: { ...m.params },
|
||||
})),
|
||||
connections: saved.connections.map(c => ({ ...c })),
|
||||
camera: { camX: 0, camY: 0, zoom: 1 },
|
||||
};
|
||||
deserialize(data);
|
||||
} else {
|
||||
const data = {
|
||||
modules: (level.preplacedModules || []).map(m => ({
|
||||
id: m.id, type: m.type, x: m.x, y: m.y, params: { ...m.params },
|
||||
})),
|
||||
connections: [],
|
||||
camera: { camX: 0, camY: 0, zoom: 1 },
|
||||
};
|
||||
deserialize(data);
|
||||
}
|
||||
setResult(null);
|
||||
// Restore persisted hint state — no cheating by reloading!
|
||||
const hintPersisted = wasHintUsed(level.id);
|
||||
setHintUsed(hintPersisted);
|
||||
setShowHint(hintPersisted); // If they used it before, show it again
|
||||
if (state.isRunning) stopAudio();
|
||||
}, [level]);
|
||||
|
||||
const handlePortPosition = useCallback((moduleId, portName, direction, el) => {
|
||||
const key = `${moduleId}-${portName}-${direction}`;
|
||||
portPositions.current[key] = el;
|
||||
}, []);
|
||||
|
||||
const handleStartConnect = useCallback((info) => {
|
||||
connectingRef.current = info;
|
||||
const containerRect = containerRef.current.getBoundingClientRect();
|
||||
setTempWire({
|
||||
portType: info.portType,
|
||||
startX: info.startX - containerRect.left,
|
||||
startY: info.startY - containerRect.top,
|
||||
endX: info.startX - containerRect.left,
|
||||
endY: info.startY - containerRect.top,
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Robust port detection — searches all port-dots by bounding rect distance
|
||||
// instead of elementFromPoint (which gets blocked by SVG wire overlay)
|
||||
const findPortAtPoint = (clientX, clientY) => {
|
||||
const portDots = document.querySelectorAll('.port-dot[data-module-id]');
|
||||
let closest = null;
|
||||
let closestDist = 18;
|
||||
for (const dot of portDots) {
|
||||
const rect = dot.getBoundingClientRect();
|
||||
const cx = rect.left + rect.width / 2;
|
||||
const cy = rect.top + rect.height / 2;
|
||||
const dist = Math.sqrt((clientX - cx) ** 2 + (clientY - cy) ** 2);
|
||||
if (dist < closestDist) {
|
||||
closestDist = dist;
|
||||
closest = dot;
|
||||
}
|
||||
}
|
||||
return closest;
|
||||
};
|
||||
|
||||
const handlePointerDown = useCallback((e) => {
|
||||
if (e.button === 1 || (e.button === 0 && e.altKey) || e.button === 2) {
|
||||
state.panning = true;
|
||||
state.panStart = { x: e.clientX - state.camX, y: e.clientY - state.camY };
|
||||
e.preventDefault();
|
||||
} else if (e.button === 0 && !connectingRef.current) {
|
||||
// On mobile (touch), single finger on empty canvas = pan
|
||||
if (isMobile && e.pointerType === 'touch') {
|
||||
state.panning = true;
|
||||
state.panStart = { x: e.clientX - state.camX, y: e.clientY - state.camY };
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
state.selectedModuleId = null;
|
||||
emit();
|
||||
}
|
||||
}, [isMobile]);
|
||||
|
||||
const handlePointerMove = useCallback((e) => {
|
||||
if (state.panning && state.panStart) {
|
||||
state.camX = e.clientX - state.panStart.x;
|
||||
state.camY = e.clientY - state.panStart.y;
|
||||
emit();
|
||||
return;
|
||||
}
|
||||
if (state.dragging) {
|
||||
const newX = e.clientX / state.zoom - state.dragging.offsetX;
|
||||
const newY = e.clientY / state.zoom - state.dragging.offsetY;
|
||||
updateModulePosition(state.dragging.moduleId, newX, newY);
|
||||
return;
|
||||
}
|
||||
if (connectingRef.current && containerRef.current) {
|
||||
const containerRect = containerRef.current.getBoundingClientRect();
|
||||
setTempWire(prev => prev ? {
|
||||
...prev,
|
||||
endX: e.clientX - containerRect.left,
|
||||
endY: e.clientY - containerRect.top,
|
||||
} : null);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handlePointerUp = useCallback((e) => {
|
||||
if (state.panning) {
|
||||
state.panning = false;
|
||||
state.panStart = null;
|
||||
}
|
||||
if (state.dragging) {
|
||||
state.dragging = null;
|
||||
emit();
|
||||
}
|
||||
if (connectingRef.current) {
|
||||
const portEl = findPortAtPoint(e.clientX, e.clientY);
|
||||
if (portEl) finishConnection(portEl);
|
||||
connectingRef.current = null;
|
||||
setTempWire(null);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const finishConnection = (portEl) => {
|
||||
const from = connectingRef.current;
|
||||
if (!from) return;
|
||||
const targetModuleId = parseInt(portEl.dataset.moduleId);
|
||||
const targetPort = portEl.dataset.portName;
|
||||
const targetDirection = portEl.dataset.portDirection;
|
||||
if (!targetModuleId || !targetPort || !targetDirection) return;
|
||||
if (targetModuleId === from.moduleId && targetPort === from.port) return;
|
||||
|
||||
let fromMod, fromPort, toMod, toPort;
|
||||
if (from.direction === 'output' && targetDirection === 'input') {
|
||||
fromMod = from.moduleId; fromPort = from.port;
|
||||
toMod = targetModuleId; toPort = targetPort;
|
||||
} else if (from.direction === 'input' && targetDirection === 'output') {
|
||||
fromMod = targetModuleId; fromPort = targetPort;
|
||||
toMod = from.moduleId; toPort = from.port;
|
||||
} else return;
|
||||
|
||||
const connId = addConnection(fromMod, fromPort, toMod, toPort);
|
||||
if (connId && state.isRunning) {
|
||||
const conn = state.connections.find(c => c.id === connId);
|
||||
if (conn) connectWire(conn);
|
||||
}
|
||||
};
|
||||
|
||||
const handleWheel = useCallback((e) => {
|
||||
e.preventDefault();
|
||||
const delta = -e.deltaY * 0.001;
|
||||
state.zoom = Math.max(0.3, Math.min(3, state.zoom + delta));
|
||||
emit();
|
||||
}, []);
|
||||
|
||||
const handleContextMenu = useCallback((e) => e.preventDefault(), []);
|
||||
|
||||
// Zoom controls (Google Maps style)
|
||||
const handleZoomIn = useCallback(() => {
|
||||
state.zoom = Math.min(3, state.zoom * 1.25);
|
||||
emit();
|
||||
}, []);
|
||||
const handleZoomOut = useCallback(() => {
|
||||
state.zoom = Math.max(0.3, state.zoom / 1.25);
|
||||
emit();
|
||||
}, []);
|
||||
const handleZoomReset = useCallback(() => {
|
||||
state.zoom = 1;
|
||||
state.camX = 0;
|
||||
state.camY = 0;
|
||||
emit();
|
||||
}, []);
|
||||
|
||||
// Center view on all modules
|
||||
const handleCenterView = useCallback(() => {
|
||||
if (state.modules.length === 0) { state.camX = 0; state.camY = 0; emit(); return; }
|
||||
const container = containerRef.current;
|
||||
const cw = container?.clientWidth || 800;
|
||||
const ch = container?.clientHeight || 600;
|
||||
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
||||
for (const m of state.modules) {
|
||||
minX = Math.min(minX, m.x);
|
||||
minY = Math.min(minY, m.y);
|
||||
maxX = Math.max(maxX, m.x + 200);
|
||||
maxY = Math.max(maxY, m.y + 150);
|
||||
}
|
||||
const cx = (minX + maxX) / 2 * state.zoom;
|
||||
const cy = (minY + maxY) / 2 * state.zoom;
|
||||
state.camX = cw / 2 - cx;
|
||||
state.camY = ch / 2 - cy;
|
||||
emit();
|
||||
}, []);
|
||||
|
||||
const handleAddModule = (type) => {
|
||||
const x = (-state.camX + 250) / state.zoom + Math.random() * 30;
|
||||
const y = (-state.camY + 150) / state.zoom + Math.random() * 30;
|
||||
addModule(type, x, y);
|
||||
if (state.isRunning) rebuildGraph();
|
||||
};
|
||||
|
||||
const handleToggleAudio = async () => {
|
||||
if (state.isRunning) {
|
||||
stopAudio();
|
||||
playEngineStop();
|
||||
} else {
|
||||
await startAudio();
|
||||
playEngineStart();
|
||||
}
|
||||
emit();
|
||||
};
|
||||
|
||||
const handlePlayTarget = async () => {
|
||||
if (isTargetPlaying()) {
|
||||
stopTarget();
|
||||
setTargetPlaying(false);
|
||||
} else {
|
||||
setTargetPlaying(true);
|
||||
await playTarget(level.target);
|
||||
setTimeout(() => setTargetPlaying(false), (level.target.duration || 2) * 1000 + 100);
|
||||
}
|
||||
};
|
||||
|
||||
// Clear canvas — remove all user-added modules and reset to preplaced only
|
||||
const handleClearCanvas = () => {
|
||||
if (state.isRunning) stopAudio();
|
||||
clearLevelPatch(level.id);
|
||||
loadLevel(true);
|
||||
};
|
||||
|
||||
// Reveal hint — PERMANENTLY caps this level at 2 stars (persisted, survives reload)
|
||||
const handleRevealHint = () => {
|
||||
setHintUsed(true);
|
||||
setShowHint(true);
|
||||
markHintUsed(level.id);
|
||||
playHint();
|
||||
};
|
||||
|
||||
const handleCheck = () => {
|
||||
const mods = state.modules;
|
||||
const conns = state.connections;
|
||||
const checks = level.checks.map(check => ({
|
||||
...check,
|
||||
passed: check.test(mods, conns),
|
||||
}));
|
||||
|
||||
let stars = 0;
|
||||
for (const check of checks) {
|
||||
if (check.passed) stars = check.star;
|
||||
else break;
|
||||
}
|
||||
|
||||
// Cap at 2 stars if hint was used
|
||||
if (hintUsed && stars > 2) stars = 2;
|
||||
|
||||
setResult({ stars, checks, hintPenalty: hintUsed && stars >= 2 });
|
||||
|
||||
if (stars >= 1) {
|
||||
completeLevel(level.id, stars);
|
||||
playLevelComplete();
|
||||
} else {
|
||||
playFail();
|
||||
}
|
||||
};
|
||||
|
||||
// Admin auto-solve — loads the actual solution modules/connections and validates naturally
|
||||
const handleAutoSolve = () => {
|
||||
const solution = SOLUTIONS[level.id];
|
||||
if (!solution) {
|
||||
console.warn(`No auto-solve solution for level ${level.id}`);
|
||||
return;
|
||||
}
|
||||
// Load the solution patch into the engine state
|
||||
deserialize(solution);
|
||||
emit();
|
||||
// Now run the normal check logic against the loaded patch
|
||||
setTimeout(() => {
|
||||
handleCheck();
|
||||
}, 50);
|
||||
};
|
||||
|
||||
const isLastLevel = levelIndex >= worldLevels.length - 1;
|
||||
|
||||
return (
|
||||
<div className="gm-puzzle">
|
||||
{/* Top bar */}
|
||||
<div className="gm-puzzle-bar">
|
||||
<button className="gm-btn icon" onClick={() => { playNav(); onBack(); }}>{isMobile ? '←' : '← Mapa'}</button>
|
||||
<div className="gm-puzzle-title">
|
||||
<span className="gm-puzzle-num">{levelIndex + 1}/{worldLevels.length}</span>
|
||||
<span className="gm-puzzle-name">{level.title}</span>
|
||||
</div>
|
||||
<div className="gm-puzzle-actions">
|
||||
<button
|
||||
className={`gm-btn ${targetPlaying ? 'active' : 'target'}`}
|
||||
onClick={handlePlayTarget}
|
||||
>
|
||||
{targetPlaying ? '⏹' : '🎯'}{!isMobile && <span className="btn-label">{targetPlaying ? ' Parar' : ' Objetivo'}</span>}
|
||||
</button>
|
||||
<button
|
||||
className={`gm-btn ${state.isRunning ? 'active' : ''}`}
|
||||
onClick={handleToggleAudio}
|
||||
>
|
||||
{state.isRunning ? '⏹' : '▶'}{!isMobile && <span className="btn-label">{state.isRunning ? ' Parar' : ' Mi Sonido'}</span>}
|
||||
</button>
|
||||
{!isMobile && (
|
||||
<button className="gm-btn clear" onClick={handleClearCanvas} title="Limpiar canvas">
|
||||
🗑 Limpiar
|
||||
</button>
|
||||
)}
|
||||
<button className="gm-btn check" onClick={handleCheck}>
|
||||
✓{!isMobile && <span className="btn-label"> Comprobar</span>}
|
||||
</button>
|
||||
{adminMode && (
|
||||
<button className="gm-btn admin-solve" onClick={handleAutoSolve} title="Admin: resolver nivel">
|
||||
🛠 Resolver
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="gm-puzzle-content">
|
||||
{/* Left sidebar (desktop only — hidden on mobile via CSS) */}
|
||||
<div className="gm-puzzle-sidebar">
|
||||
{/* Description — always visible */}
|
||||
<div className="gm-concept-panel">
|
||||
<div className="gm-concept-header">
|
||||
<span>📖 Mision</span>
|
||||
</div>
|
||||
<div className="gm-concept-body">
|
||||
<p className="gm-concept-desc">{level.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Hint — hidden, reveals with penalty */}
|
||||
<div className="gm-hint-panel">
|
||||
{!showHint ? (
|
||||
<button className="gm-hint-btn" onClick={handleRevealHint}>
|
||||
<span className="gm-hint-icon">💡</span>
|
||||
<span className="gm-hint-label">Mostrar Pista</span>
|
||||
<span className="gm-hint-penalty">max ★★</span>
|
||||
</button>
|
||||
) : (
|
||||
<div className="gm-hint-revealed">
|
||||
<div className="gm-hint-header">
|
||||
<span>💡 Pista</span>
|
||||
<span className="gm-hint-penalty-tag">max ★★</span>
|
||||
</div>
|
||||
<p className="gm-hint-text">{level.concept}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Objectives */}
|
||||
<div className="gm-objectives">
|
||||
<div className="gm-obj-title">Objetivos</div>
|
||||
{level.checks.map((check, i) => {
|
||||
const passed = result?.checks?.[i]?.passed;
|
||||
const cappedByStar = hintUsed && check.star === 3;
|
||||
return (
|
||||
<div key={i} className={`gm-obj ${passed === true ? (cappedByStar ? 'capped' : 'passed') : passed === false ? 'failed' : ''}`}>
|
||||
<span className="gm-obj-star">{'★'.repeat(check.star)}</span>
|
||||
<span className="gm-obj-name">
|
||||
{check.desc}
|
||||
{cappedByStar && <span className="gm-obj-locked"> 🔒</span>}
|
||||
</span>
|
||||
{passed === true && !cappedByStar && <span className="gm-obj-check">✓</span>}
|
||||
{passed === false && <span className="gm-obj-x">✗</span>}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{hintUsed && (
|
||||
<div className="gm-hint-warning">
|
||||
Pista usada — maximo 2 estrellas en este nivel (permanente).
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Module palette */}
|
||||
{level.availableModules.length > 0 && (
|
||||
<div className="gm-module-palette">
|
||||
<div className="gm-palette-title">Modulos Disponibles</div>
|
||||
{level.availableModules.map(type => {
|
||||
const def = getModuleDef(type);
|
||||
if (!def) return null;
|
||||
return (
|
||||
<div key={type} className="gm-palette-item" onClick={() => handleAddModule(type)}>
|
||||
<span className="gm-palette-icon">{def.icon}</span>
|
||||
<span className="gm-palette-name">{def.name}</span>
|
||||
<span className="gm-palette-add">+</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button className="gm-btn danger" onClick={() => loadLevel(true)} style={{ marginTop: 'auto' }}>
|
||||
↺ Reiniciar Nivel
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Main canvas */}
|
||||
<div className="gm-puzzle-canvas-wrap">
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={`node-canvas ${state.panning ? 'grabbing' : ''} ${connectingRef.current ? 'connecting' : ''}`}
|
||||
onPointerDown={handlePointerDown}
|
||||
onPointerMove={handlePointerMove}
|
||||
onPointerUp={handlePointerUp}
|
||||
onWheel={handleWheel}
|
||||
onContextMenu={handleContextMenu}
|
||||
>
|
||||
<svg style={{ position: 'absolute', inset: 0, width: '100%', height: '100%', zIndex: 0 }}>
|
||||
<defs>
|
||||
<pattern id="puzzle-grid" width={20 * state.zoom} height={20 * state.zoom}
|
||||
patternUnits="userSpaceOnUse"
|
||||
x={state.camX % (20 * state.zoom)} y={state.camY % (20 * state.zoom)}>
|
||||
<circle cx={1} cy={1} r={0.8} fill="#1a1a30" />
|
||||
</pattern>
|
||||
</defs>
|
||||
<rect width="100%" height="100%" fill="url(#puzzle-grid)" />
|
||||
</svg>
|
||||
|
||||
<WireLayer portPositions={portPositions} tempWire={tempWire} containerRef={containerRef} zoom={state.zoom} camX={state.camX} camY={state.camY} />
|
||||
|
||||
<div style={{ position: 'absolute', left: state.camX, top: state.camY, zIndex: 2 }}>
|
||||
{state.modules.map(mod => (
|
||||
<ModuleNode
|
||||
key={mod.id}
|
||||
mod={mod}
|
||||
zoom={state.zoom}
|
||||
onStartConnect={handleStartConnect}
|
||||
onPortPosition={handlePortPosition}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Zoom controls — top right */}
|
||||
<div className="zoom-controls">
|
||||
<button className="zoom-btn" onClick={handleZoomIn} title="Acercar">+</button>
|
||||
<button className="zoom-btn zoom-label" onClick={handleZoomReset} title="Resetear zoom">
|
||||
{(state.zoom * 100).toFixed(0)}%
|
||||
</button>
|
||||
<button className="zoom-btn" onClick={handleZoomOut} title="Alejar">−</button>
|
||||
<button className="zoom-btn" onClick={handleCenterView} title="Centrar vista">⌂</button>
|
||||
</div>
|
||||
|
||||
{state.modules.length > 0 && state.connections.length === 0 && (
|
||||
<div className="gm-canvas-hint">
|
||||
Arrastra de un puerto (circulo) a otro para conectar modulos
|
||||
</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 */}
|
||||
{result && result.stars >= 1 && (
|
||||
<LevelComplete
|
||||
stars={result.stars}
|
||||
checks={result.checks}
|
||||
levelTitle={level.title}
|
||||
isLastLevel={isLastLevel}
|
||||
hintPenalty={result.hintPenalty}
|
||||
onRetry={() => setResult(null)}
|
||||
onMap={onBack}
|
||||
onNext={onNextLevel}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
235
packages/client/src/game/WorldMap.jsx
Normal file
235
packages/client/src/game/WorldMap.jsx
Normal file
@@ -0,0 +1,235 @@
|
||||
import React, { useState, useRef } from 'react';
|
||||
import MobileTabBar from '../components/MobileTabBar.jsx';
|
||||
import { useIsMobile } from '../hooks/useIsMobile.js';
|
||||
import { WORLD_1 } from './levels/world1.js';
|
||||
import { WORLD_2 } from './levels/world2.js';
|
||||
import { WORLD_3 } from './levels/world3.js';
|
||||
import { WORLD_4 } from './levels/world4.js';
|
||||
import { WORLD_5 } from './levels/world5.js';
|
||||
import { WORLD_6 } from './levels/world6.js';
|
||||
import { WORLD_7 } from './levels/world7.js';
|
||||
import { WORLD_8 } from './levels/world8.js';
|
||||
import { WORLD_9 } from './levels/world9.js';
|
||||
import { WORLD_10 } from './levels/world10.js';
|
||||
import { WORLD_11 } from './levels/world11.js';
|
||||
import { WORLD_12 } from './levels/world12.js';
|
||||
import { getLevelProgress, isLevelUnlocked, loadProgress } from './gameState.js';
|
||||
|
||||
const worlds = [WORLD_1, WORLD_2, WORLD_3, WORLD_4, WORLD_5, WORLD_6, WORLD_7, WORLD_8, WORLD_9, WORLD_10, WORLD_11, WORLD_12];
|
||||
|
||||
function Stars({ count, max = 3 }) {
|
||||
return (
|
||||
<span className="gm-stars">
|
||||
{Array.from({ length: max }, (_, i) => (
|
||||
<span key={i} className={i < count ? 'star filled' : 'star empty'}>★</span>
|
||||
))}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function getTotalStars() {
|
||||
const p = loadProgress();
|
||||
return Object.values(p.completedLevels).reduce((s, l) => s + (l.stars || 0), 0);
|
||||
}
|
||||
|
||||
function getMaxStars() {
|
||||
return worlds.reduce((s, w) => s + w.levels.length * 3, 0);
|
||||
}
|
||||
|
||||
function isWorldUnlocked(world) {
|
||||
if (!world.unlockStars) return true; // World 1 always unlocked
|
||||
return getTotalStars() >= world.unlockStars;
|
||||
}
|
||||
|
||||
const MOBILE_TABS = [
|
||||
{ id: 'game', label: 'JUEGO', icon: '🎮' },
|
||||
{ id: 'sandbox', label: 'SANDBOX', icon: '🎛' },
|
||||
{ id: 'config', label: 'CONFIG', icon: '⚙' },
|
||||
];
|
||||
|
||||
export default function WorldMap({ onSelectLevel, onSandbox, onAdmin }) {
|
||||
const totalStars = getTotalStars();
|
||||
const maxStars = getMaxStars();
|
||||
const [search, setSearch] = useState('');
|
||||
const searchRef = useRef(null);
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
const query = search.trim().toLowerCase();
|
||||
|
||||
// Build flat search results when there's a query
|
||||
const searchResults = query ? worlds.flatMap((world, worldIdx) => {
|
||||
return world.levels.map((level, idx) => ({ level, world, worldIdx, idx }))
|
||||
.filter(({ level }) =>
|
||||
level.title.toLowerCase().includes(query) ||
|
||||
level.subtitle.toLowerCase().includes(query) ||
|
||||
level.id.toLowerCase().includes(query) ||
|
||||
world.name.toLowerCase().includes(query)
|
||||
);
|
||||
}) : [];
|
||||
|
||||
return (
|
||||
<div className="gm-worldmap">
|
||||
{/* Header */}
|
||||
<div className="gm-header">
|
||||
<div className="gm-logo">
|
||||
<span className="gm-logo-icon">~</span>
|
||||
<div>
|
||||
<h1 className="gm-title">SynthQuest</h1>
|
||||
<p className="gm-tagline">Aprende sintesis modular resolviendo puzzles</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="gm-header-right">
|
||||
<div className="gm-total-stars">
|
||||
<span className="star filled">★</span> {totalStars}/{maxStars}
|
||||
</div>
|
||||
<button className="gm-sandbox-btn" onClick={onSandbox}>
|
||||
🎛 Sandbox
|
||||
</button>
|
||||
{onAdmin && (
|
||||
<button className="gm-admin-btn" onClick={onAdmin} title="Admin Mode">
|
||||
🛠
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Search bar */}
|
||||
<div className="gm-search-bar">
|
||||
<span className="gm-search-icon">🔍</span>
|
||||
<input
|
||||
ref={searchRef}
|
||||
className="gm-search-input"
|
||||
type="text"
|
||||
placeholder="Buscar nivel por nombre, mundo..."
|
||||
value={search}
|
||||
onChange={e => setSearch(e.target.value)}
|
||||
onKeyDown={e => e.key === 'Escape' && (setSearch(''), searchRef.current?.blur())}
|
||||
/>
|
||||
{search && (
|
||||
<button className="gm-search-clear" onClick={() => { setSearch(''); searchRef.current?.focus(); }}>✕</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Search results */}
|
||||
{query ? (
|
||||
<div className="gm-search-results">
|
||||
{searchResults.length === 0 ? (
|
||||
<div className="gm-search-empty">No se encontraron niveles para "{search}"</div>
|
||||
) : (
|
||||
<div className="gm-search-count">{searchResults.length} nivel{searchResults.length !== 1 ? 'es' : ''} encontrado{searchResults.length !== 1 ? 's' : ''}</div>
|
||||
)}
|
||||
<div className="gm-level-grid">
|
||||
{searchResults.map(({ level, world, worldIdx, idx }) => {
|
||||
const progress = getLevelProgress(level.id);
|
||||
const levelUnlocked = isLevelUnlocked(level.id, world.levels) && isWorldUnlocked(world);
|
||||
const stars = progress?.stars || 0;
|
||||
const isBoss = idx === world.levels.length - 1;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={level.id}
|
||||
className={`gm-level-card ${levelUnlocked ? 'unlocked' : 'locked'} ${isBoss ? 'boss' : ''} ${stars === 3 ? 'perfect' : ''}`}
|
||||
onClick={() => levelUnlocked && onSelectLevel(level, world)}
|
||||
>
|
||||
<div className="gm-level-number" style={{ color: world.color }}>{worldIdx + 1}.{idx + 1}</div>
|
||||
<div className="gm-level-info">
|
||||
<h3 className="gm-level-title">{level.title}</h3>
|
||||
<p className="gm-level-subtitle">{world.name} — {level.subtitle}</p>
|
||||
</div>
|
||||
{levelUnlocked ? (
|
||||
<Stars count={stars} />
|
||||
) : (
|
||||
<span className="gm-lock">🔒</span>
|
||||
)}
|
||||
{!levelUnlocked && <div className="gm-lock-overlay" />}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
|
||||
/* All worlds (normal view) */
|
||||
worlds.map((world, worldIdx) => {
|
||||
const unlocked = isWorldUnlocked(world);
|
||||
const worldStars = world.levels.reduce((s, l) => {
|
||||
const p = getLevelProgress(l.id);
|
||||
return s + (p?.stars || 0);
|
||||
}, 0);
|
||||
const worldMaxStars = world.levels.length * 3;
|
||||
|
||||
if (!unlocked) {
|
||||
return (
|
||||
<div key={world.id} className="gm-world-section gm-locked-world">
|
||||
<div className="gm-world-header">
|
||||
<span className="gm-world-icon" style={{ color: '#666' }}>{world.icon}</span>
|
||||
<div>
|
||||
<h2 className="gm-world-name" style={{ color: '#666' }}>Mundo {worldIdx + 1}: {world.name}</h2>
|
||||
<p className="gm-world-sub">Consigue {world.unlockStars} estrellas para desbloquear ({totalStars}/{world.unlockStars})</p>
|
||||
</div>
|
||||
<span className="gm-lock" style={{ fontSize: 24 }}>🔒</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={world.id} className="gm-world-section">
|
||||
<div className="gm-world-header">
|
||||
<span className="gm-world-icon" style={{ color: world.color }}>{world.icon}</span>
|
||||
<div>
|
||||
<h2 className="gm-world-name">Mundo {worldIdx + 1}: {world.name}</h2>
|
||||
<p className="gm-world-sub">{world.subtitle}</p>
|
||||
</div>
|
||||
<div className="gm-world-stars">
|
||||
<span className="star filled">★</span> {worldStars}/{worldMaxStars}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="gm-level-grid">
|
||||
{world.levels.map((level, idx) => {
|
||||
const progress = getLevelProgress(level.id);
|
||||
const levelUnlocked = isLevelUnlocked(level.id, world.levels);
|
||||
const stars = progress?.stars || 0;
|
||||
const isBoss = idx === world.levels.length - 1;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={level.id}
|
||||
className={`gm-level-card ${levelUnlocked ? 'unlocked' : 'locked'} ${isBoss ? 'boss' : ''} ${stars === 3 ? 'perfect' : ''}`}
|
||||
onClick={() => levelUnlocked && onSelectLevel(level, world)}
|
||||
>
|
||||
<div className="gm-level-number">{idx + 1}</div>
|
||||
<div className="gm-level-info">
|
||||
<h3 className="gm-level-title">{level.title}</h3>
|
||||
<p className="gm-level-subtitle">{level.subtitle}</p>
|
||||
</div>
|
||||
{levelUnlocked ? (
|
||||
<Stars count={stars} />
|
||||
) : (
|
||||
<span className="gm-lock">🔒</span>
|
||||
)}
|
||||
{!levelUnlocked && <div className="gm-lock-overlay" />}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
|
||||
{/* Mobile tab bar */}
|
||||
{isMobile && (
|
||||
<MobileTabBar
|
||||
tabs={MOBILE_TABS}
|
||||
activeTab="game"
|
||||
onTabChange={(id) => {
|
||||
if (id === 'sandbox') onSandbox?.();
|
||||
if (id === 'config') onAdmin?.();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
1913
packages/client/src/game/autoSolver.js
Normal file
1913
packages/client/src/game/autoSolver.js
Normal file
File diff suppressed because it is too large
Load Diff
149
packages/client/src/game/gameState.js
Normal file
149
packages/client/src/game/gameState.js
Normal file
@@ -0,0 +1,149 @@
|
||||
/**
|
||||
* gameState.js — Game progress persistence
|
||||
* Tracks completed levels, stars earned, unlocks, and saved patches per level
|
||||
*/
|
||||
|
||||
const STORAGE_KEY = 'synthquest-progress';
|
||||
const PATCHES_KEY = 'synthquest-patches';
|
||||
|
||||
const defaultProgress = {
|
||||
currentWorld: 'w1',
|
||||
completedLevels: {}, // { levelId: { stars: 3 } }
|
||||
unlockedWorlds: ['w1'],
|
||||
totalStars: 0,
|
||||
};
|
||||
|
||||
let _progress = null;
|
||||
let _patches = null; // { levelId: { modules, connections } }
|
||||
|
||||
export function loadProgress() {
|
||||
if (_progress) return _progress;
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY);
|
||||
_progress = raw ? { ...defaultProgress, ...JSON.parse(raw) } : { ...defaultProgress };
|
||||
} catch {
|
||||
_progress = { ...defaultProgress };
|
||||
}
|
||||
return _progress;
|
||||
}
|
||||
|
||||
export function saveProgress() {
|
||||
if (!_progress) return;
|
||||
_progress.totalStars = Object.values(_progress.completedLevels)
|
||||
.reduce((sum, l) => sum + (l.stars || 0), 0);
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(_progress));
|
||||
} catch {}
|
||||
}
|
||||
|
||||
export function completeLevel(levelId, stars) {
|
||||
const p = loadProgress();
|
||||
const existing = p.completedLevels[levelId];
|
||||
if (!existing || stars > existing.stars) {
|
||||
p.completedLevels[levelId] = { stars, completedAt: Date.now() };
|
||||
}
|
||||
saveProgress();
|
||||
}
|
||||
|
||||
export function getLevelProgress(levelId) {
|
||||
const p = loadProgress();
|
||||
return p.completedLevels[levelId] || null;
|
||||
}
|
||||
|
||||
export function isLevelUnlocked(levelId, worldLevels) {
|
||||
const p = loadProgress();
|
||||
const idx = worldLevels.findIndex(l => l.id === levelId);
|
||||
if (idx === 0) return true;
|
||||
const prevId = worldLevels[idx - 1]?.id;
|
||||
return prevId && p.completedLevels[prevId]?.stars >= 1;
|
||||
}
|
||||
|
||||
export function resetProgress() {
|
||||
_progress = { ...defaultProgress };
|
||||
_patches = {};
|
||||
_hints = {};
|
||||
saveProgress();
|
||||
savePatches();
|
||||
saveHints();
|
||||
}
|
||||
|
||||
// ==================== Level patch persistence ====================
|
||||
|
||||
function loadPatches() {
|
||||
if (_patches) return _patches;
|
||||
try {
|
||||
const raw = localStorage.getItem(PATCHES_KEY);
|
||||
_patches = raw ? JSON.parse(raw) : {};
|
||||
} catch {
|
||||
_patches = {};
|
||||
}
|
||||
return _patches;
|
||||
}
|
||||
|
||||
function savePatches() {
|
||||
if (!_patches) return;
|
||||
try {
|
||||
localStorage.setItem(PATCHES_KEY, JSON.stringify(_patches));
|
||||
} catch {}
|
||||
}
|
||||
|
||||
export function saveLevelPatch(levelId, modules, connections) {
|
||||
const patches = loadPatches();
|
||||
patches[levelId] = {
|
||||
modules: modules.map(m => ({ id: m.id, type: m.type, x: m.x, y: m.y, params: { ...m.params } })),
|
||||
connections: connections.map(c => ({ ...c })),
|
||||
savedAt: Date.now(),
|
||||
};
|
||||
savePatches();
|
||||
}
|
||||
|
||||
export function getLevelPatch(levelId) {
|
||||
const patches = loadPatches();
|
||||
return patches[levelId] || null;
|
||||
}
|
||||
|
||||
export function clearLevelPatch(levelId) {
|
||||
const patches = loadPatches();
|
||||
delete patches[levelId];
|
||||
savePatches();
|
||||
}
|
||||
|
||||
// ==================== Hint tracking (persisted, no cheating!) ====================
|
||||
|
||||
const HINTS_KEY = 'synthquest-hints';
|
||||
let _hints = null; // { levelId: true }
|
||||
|
||||
function loadHints() {
|
||||
if (_hints) return _hints;
|
||||
try {
|
||||
const raw = localStorage.getItem(HINTS_KEY);
|
||||
_hints = raw ? JSON.parse(raw) : {};
|
||||
} catch {
|
||||
_hints = {};
|
||||
}
|
||||
return _hints;
|
||||
}
|
||||
|
||||
function saveHints() {
|
||||
if (!_hints) return;
|
||||
try {
|
||||
localStorage.setItem(HINTS_KEY, JSON.stringify(_hints));
|
||||
} catch {}
|
||||
}
|
||||
|
||||
export function markHintUsed(levelId) {
|
||||
const hints = loadHints();
|
||||
hints[levelId] = true;
|
||||
saveHints();
|
||||
}
|
||||
|
||||
export function wasHintUsed(levelId) {
|
||||
const hints = loadHints();
|
||||
return !!hints[levelId];
|
||||
}
|
||||
|
||||
export function clearHintForLevel(levelId) {
|
||||
const hints = loadHints();
|
||||
delete hints[levelId];
|
||||
saveHints();
|
||||
}
|
||||
481
packages/client/src/game/levels/world1.js
Normal file
481
packages/client/src/game/levels/world1.js
Normal file
@@ -0,0 +1,481 @@
|
||||
/**
|
||||
* World 1 — "Ondas" (Waves)
|
||||
*
|
||||
* Teaches: oscillators, waveforms, frequency, mixing
|
||||
* 8 levels, progressive difficulty
|
||||
*/
|
||||
|
||||
export const WORLD_1 = {
|
||||
id: 'w1',
|
||||
name: 'Ondas',
|
||||
subtitle: 'Los bloques fundamentales del sonido',
|
||||
icon: '~',
|
||||
color: '#00e5ff',
|
||||
levels: [
|
||||
// ─────────────── LEVEL 1.1 ───────────────
|
||||
{
|
||||
id: 'w1-1',
|
||||
title: 'Tu Primer Sonido',
|
||||
subtitle: 'Conecta y escucha',
|
||||
description: 'Todo sonido en un sintetizador empieza con un oscilador. Un oscilador genera una onda que vibra a una frecuencia determinada. Conecta el oscilador a la salida para escuchar tu primer sonido.',
|
||||
concept: 'Un oscilador genera una onda. La salida (Output) envía el sonido a tus altavoces. Conecta la salida del oscilador al input de la salida arrastrando de un puerto a otro.',
|
||||
availableModules: [], // No new modules to add, just connect preplaced
|
||||
preplacedModules: [
|
||||
{ id: 1, type: 'oscillator', x: 100, y: 80, params: { waveform: 'sine', frequency: 440, detune: 0 }, locked: true },
|
||||
{ id: 2, type: 'output', x: 500, y: 100, params: { volume: -6 }, locked: true },
|
||||
],
|
||||
target: {
|
||||
// Simple sine at 440Hz
|
||||
build: [
|
||||
{ type: 'oscillator', params: { waveform: 'sine', frequency: 440 } },
|
||||
],
|
||||
duration: 2,
|
||||
},
|
||||
checks: [
|
||||
{
|
||||
star: 1,
|
||||
name: 'Sonido conectado',
|
||||
desc: 'Conecta el oscilador a la salida',
|
||||
test: (mods, conns) => {
|
||||
const osc = mods.find(m => m.type === 'oscillator');
|
||||
const out = mods.find(m => m.type === 'output');
|
||||
if (!osc || !out) return false;
|
||||
return conns.some(c =>
|
||||
c.from.moduleId === osc.id && c.from.port === 'out' &&
|
||||
c.to.moduleId === out.id
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 2,
|
||||
name: 'Canal izquierdo',
|
||||
desc: 'Conecta al canal izquierdo (Left)',
|
||||
test: (mods, conns) => {
|
||||
const osc = mods.find(m => m.type === 'oscillator');
|
||||
const out = mods.find(m => m.type === 'output');
|
||||
if (!osc || !out) return false;
|
||||
return conns.some(c =>
|
||||
c.from.moduleId === osc.id && c.from.port === 'out' &&
|
||||
c.to.moduleId === out.id && c.to.port === 'left'
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 3,
|
||||
name: 'Estéreo completo',
|
||||
desc: 'Conecta también al canal derecho (Right)',
|
||||
test: (mods, conns) => {
|
||||
const osc = mods.find(m => m.type === 'oscillator');
|
||||
const out = mods.find(m => m.type === 'output');
|
||||
if (!osc || !out) return false;
|
||||
const hasLeft = conns.some(c =>
|
||||
c.from.moduleId === osc.id && c.to.moduleId === out.id && c.to.port === 'left'
|
||||
);
|
||||
const hasRight = conns.some(c =>
|
||||
c.from.moduleId === osc.id && c.to.moduleId === out.id && c.to.port === 'right'
|
||||
);
|
||||
return hasLeft && hasRight;
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// ─────────────── LEVEL 1.2 ───────────────
|
||||
{
|
||||
id: 'w1-2',
|
||||
title: 'La Nota La',
|
||||
subtitle: 'Afinación: 440 Hz',
|
||||
description: 'La nota La central (A4) vibra a exactamente 440 Hz. Es la referencia universal para afinar instrumentos. Coloca un oscilador, ajústalo a 440 Hz y conéctalo a la salida.',
|
||||
concept: 'La frecuencia se mide en Hertz (Hz) — cuántas veces vibra la onda por segundo. 440 Hz = nota La. Usa el knob de frecuencia para ajustar.',
|
||||
availableModules: ['oscillator'],
|
||||
preplacedModules: [
|
||||
{ id: 1, type: 'output', x: 500, y: 100, params: { volume: -6 }, locked: true },
|
||||
],
|
||||
target: {
|
||||
build: [
|
||||
{ type: 'oscillator', params: { waveform: 'sine', frequency: 440 } },
|
||||
],
|
||||
duration: 2,
|
||||
},
|
||||
checks: [
|
||||
{
|
||||
star: 1,
|
||||
name: 'Oscilador conectado',
|
||||
desc: 'Coloca un oscilador y conéctalo a la salida',
|
||||
test: (mods, conns) => {
|
||||
const osc = mods.find(m => m.type === 'oscillator');
|
||||
const out = mods.find(m => m.type === 'output');
|
||||
if (!osc || !out) return false;
|
||||
return conns.some(c =>
|
||||
c.from.moduleId === osc.id && c.to.moduleId === out.id
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 2,
|
||||
name: 'Frecuencia cercana',
|
||||
desc: 'Ajusta la frecuencia cerca de 440 Hz (±50 Hz)',
|
||||
test: (mods) => {
|
||||
const osc = mods.find(m => m.type === 'oscillator');
|
||||
if (!osc) return false;
|
||||
const f = osc.params.frequency ?? 440;
|
||||
return Math.abs(f - 440) <= 50;
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 3,
|
||||
name: 'Afinación perfecta',
|
||||
desc: 'Ajusta la frecuencia exacta a 440 Hz (±10 Hz)',
|
||||
test: (mods) => {
|
||||
const osc = mods.find(m => m.type === 'oscillator');
|
||||
if (!osc) return false;
|
||||
const f = osc.params.frequency ?? 440;
|
||||
return Math.abs(f - 440) <= 10;
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// ─────────────── LEVEL 1.3 ───────────────
|
||||
{
|
||||
id: 'w1-3',
|
||||
title: 'Onda Cuadrada',
|
||||
subtitle: 'El sonido 8-bit',
|
||||
description: 'La onda cuadrada es EL sonido de los videojuegos retro. El chip de sonido del NES y el Game Boy usaban ondas cuadradas. Escucha el objetivo y replica ese timbre.',
|
||||
concept: 'Cada forma de onda tiene un timbre diferente. La onda cuadrada suena brillante y "digital" porque contiene solo armónicos impares. Cambia el selector de onda (Wave) a "square".',
|
||||
availableModules: ['oscillator'],
|
||||
preplacedModules: [
|
||||
{ id: 1, type: 'output', x: 500, y: 100, params: { volume: -6 }, locked: true },
|
||||
],
|
||||
target: {
|
||||
build: [
|
||||
{ type: 'oscillator', params: { waveform: 'square', frequency: 440 } },
|
||||
],
|
||||
duration: 2,
|
||||
},
|
||||
checks: [
|
||||
{
|
||||
star: 1,
|
||||
name: 'Sonido conectado',
|
||||
desc: 'Oscilador conectado a la salida',
|
||||
test: (mods, conns) => {
|
||||
const osc = mods.find(m => m.type === 'oscillator');
|
||||
const out = mods.find(m => m.type === 'output');
|
||||
return osc && out && conns.some(c =>
|
||||
c.from.moduleId === osc.id && c.to.moduleId === out.id
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 2,
|
||||
name: 'Onda cuadrada',
|
||||
desc: 'Cambia la forma de onda a square',
|
||||
test: (mods) => {
|
||||
const osc = mods.find(m => m.type === 'oscillator');
|
||||
return osc && (osc.params.waveform === 'square');
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 3,
|
||||
name: 'Match perfecto',
|
||||
desc: 'Cuadrada a 440 Hz (±10 Hz)',
|
||||
test: (mods) => {
|
||||
const osc = mods.find(m => m.type === 'oscillator');
|
||||
if (!osc) return false;
|
||||
return osc.params.waveform === 'square' && Math.abs((osc.params.frequency ?? 440) - 440) <= 10;
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// ─────────────── LEVEL 1.4 ───────────────
|
||||
{
|
||||
id: 'w1-4',
|
||||
title: 'Diente de Sierra',
|
||||
subtitle: 'Rica en armónicos',
|
||||
description: 'La onda diente de sierra (sawtooth) contiene TODOS los armónicos, lo que le da un sonido brillante y rico. Es la base de muchos sonidos de sintetizador clásicos, desde pads hasta leads.',
|
||||
concept: 'Sawtooth = todos los armónicos. Sine = solo la fundamental. Square = armónicos impares. Triangle = armónicos impares atenuados. Cada forma de onda tiene un carácter único.',
|
||||
availableModules: ['oscillator'],
|
||||
preplacedModules: [
|
||||
{ id: 1, type: 'output', x: 500, y: 100, params: { volume: -6 }, locked: true },
|
||||
],
|
||||
target: {
|
||||
build: [
|
||||
{ type: 'oscillator', params: { waveform: 'sawtooth', frequency: 440 } },
|
||||
],
|
||||
duration: 2,
|
||||
},
|
||||
checks: [
|
||||
{
|
||||
star: 1,
|
||||
name: 'Sonido conectado',
|
||||
desc: 'Oscilador conectado a la salida',
|
||||
test: (mods, conns) => {
|
||||
const osc = mods.find(m => m.type === 'oscillator');
|
||||
const out = mods.find(m => m.type === 'output');
|
||||
return osc && out && conns.some(c =>
|
||||
c.from.moduleId === osc.id && c.to.moduleId === out.id
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 2,
|
||||
name: 'Diente de sierra',
|
||||
desc: 'Cambia la onda a sawtooth',
|
||||
test: (mods) => {
|
||||
const osc = mods.find(m => m.type === 'oscillator');
|
||||
return osc && osc.params.waveform === 'sawtooth';
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 3,
|
||||
name: 'Match perfecto',
|
||||
desc: 'Sawtooth a 440 Hz (±10 Hz)',
|
||||
test: (mods) => {
|
||||
const osc = mods.find(m => m.type === 'oscillator');
|
||||
if (!osc) return false;
|
||||
return osc.params.waveform === 'sawtooth' && Math.abs((osc.params.frequency ?? 440) - 440) <= 10;
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// ─────────────── LEVEL 1.5 ───────────────
|
||||
{
|
||||
id: 'w1-5',
|
||||
title: 'La Octava',
|
||||
subtitle: 'Doble o mitad',
|
||||
description: 'Una octava es la relación más fundamental en la música. Cuando doblas la frecuencia, subes una octava. Cuando la divides por dos, bajas una octava. Si La4 es 440 Hz, La3 es 220 Hz.',
|
||||
concept: 'Octava arriba = frecuencia × 2. Octava abajo = frecuencia ÷ 2. Ejemplo: A4=440Hz, A3=220Hz, A5=880Hz. Ajusta el knob a 220 Hz para bajar una octava.',
|
||||
availableModules: ['oscillator'],
|
||||
preplacedModules: [
|
||||
{ id: 1, type: 'output', x: 500, y: 100, params: { volume: -6 }, locked: true },
|
||||
],
|
||||
target: {
|
||||
build: [
|
||||
{ type: 'oscillator', params: { waveform: 'sine', frequency: 220 } },
|
||||
],
|
||||
duration: 2,
|
||||
},
|
||||
checks: [
|
||||
{
|
||||
star: 1,
|
||||
name: 'Sonido conectado',
|
||||
desc: 'Oscilador conectado a la salida',
|
||||
test: (mods, conns) => {
|
||||
const osc = mods.find(m => m.type === 'oscillator');
|
||||
const out = mods.find(m => m.type === 'output');
|
||||
return osc && out && conns.some(c =>
|
||||
c.from.moduleId === osc.id && c.to.moduleId === out.id
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 2,
|
||||
name: 'Octava baja',
|
||||
desc: 'Frecuencia cercana a 220 Hz (±30 Hz)',
|
||||
test: (mods) => {
|
||||
const osc = mods.find(m => m.type === 'oscillator');
|
||||
if (!osc) return false;
|
||||
return Math.abs((osc.params.frequency ?? 440) - 220) <= 30;
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 3,
|
||||
name: 'Afinación perfecta',
|
||||
desc: 'Exactamente 220 Hz (±10 Hz)',
|
||||
test: (mods) => {
|
||||
const osc = mods.find(m => m.type === 'oscillator');
|
||||
if (!osc) return false;
|
||||
return Math.abs((osc.params.frequency ?? 440) - 220) <= 10;
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// ─────────────── LEVEL 1.6 ───────────────
|
||||
{
|
||||
id: 'w1-6',
|
||||
title: 'Dos Voces',
|
||||
subtitle: 'La quinta perfecta',
|
||||
description: 'Mezclar dos frecuencias crea armonía. La relación 3:2 entre dos notas es la "quinta perfecta" — el intervalo más consonante después de la octava. Mezcla 440 Hz con 660 Hz.',
|
||||
concept: 'Un Mixer combina varias señales de audio en una sola. Conecta varios osciladores a las entradas del mixer, y la salida del mixer al output. 440×1.5 = 660 Hz (quinta perfecta).',
|
||||
availableModules: ['oscillator', 'mixer'],
|
||||
preplacedModules: [
|
||||
{ id: 1, type: 'output', x: 700, y: 140, params: { volume: -6 }, locked: true },
|
||||
],
|
||||
target: {
|
||||
build: [
|
||||
{ type: 'oscillator', params: { waveform: 'sine', frequency: 440 } },
|
||||
{ type: 'oscillator', params: { waveform: 'sine', frequency: 660 } },
|
||||
],
|
||||
duration: 2.5,
|
||||
},
|
||||
checks: [
|
||||
{
|
||||
star: 1,
|
||||
name: 'Dos osciladores',
|
||||
desc: 'Coloca 2 osciladores y un mixer, conectados a la salida',
|
||||
test: (mods, conns) => {
|
||||
const oscs = mods.filter(m => m.type === 'oscillator');
|
||||
const mixer = mods.find(m => m.type === 'mixer');
|
||||
const out = mods.find(m => m.type === 'output');
|
||||
if (oscs.length < 2 || !mixer || !out) return false;
|
||||
// Check mixer → output
|
||||
const mixerToOut = conns.some(c => c.from.moduleId === mixer.id && c.to.moduleId === out.id);
|
||||
// Check at least one osc → mixer
|
||||
const oscToMixer = oscs.some(o => conns.some(c => c.from.moduleId === o.id && c.to.moduleId === mixer.id));
|
||||
return mixerToOut && oscToMixer;
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 2,
|
||||
name: 'Ambos conectados',
|
||||
desc: 'Ambos osciladores al mixer, mixer a la salida',
|
||||
test: (mods, conns) => {
|
||||
const oscs = mods.filter(m => m.type === 'oscillator');
|
||||
const mixer = mods.find(m => m.type === 'mixer');
|
||||
const out = mods.find(m => m.type === 'output');
|
||||
if (oscs.length < 2 || !mixer || !out) return false;
|
||||
const mixerToOut = conns.some(c => c.from.moduleId === mixer.id && c.to.moduleId === out.id);
|
||||
const bothToMixer = oscs.every(o => conns.some(c => c.from.moduleId === o.id && c.to.moduleId === mixer.id));
|
||||
return mixerToOut && bothToMixer;
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 3,
|
||||
name: 'Quinta perfecta',
|
||||
desc: 'Frecuencias a ~440 Hz y ~660 Hz (±20 Hz)',
|
||||
test: (mods, conns) => {
|
||||
const oscs = mods.filter(m => m.type === 'oscillator');
|
||||
if (oscs.length < 2) return false;
|
||||
const freqs = oscs.map(o => o.params.frequency ?? 440).sort((a, b) => a - b);
|
||||
return Math.abs(freqs[0] - 440) <= 20 && Math.abs(freqs[1] - 660) <= 20;
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// ─────────────── LEVEL 1.7 ───────────────
|
||||
{
|
||||
id: 'w1-7',
|
||||
title: 'El Unísono Gordo',
|
||||
subtitle: 'Detune = grosor',
|
||||
description: 'Los sintetizadores analógicos clásicos nunca estaban perfectamente afinados, y eso les daba un sonido "gordo". Desafinar ligeramente dos osciladores crea un efecto de coro natural. Usa dos sierras con un ligero detune.',
|
||||
concept: 'Detune = desafinación en "cents" (centésimas de semitono). Un detune de +7 a +12 cents crea un unísono gordo sin que suene desafinado. Usa dos osciladores sawtooth a la misma frecuencia pero con detune diferente.',
|
||||
availableModules: ['oscillator', 'mixer'],
|
||||
preplacedModules: [
|
||||
{ id: 1, type: 'output', x: 700, y: 140, params: { volume: -6 }, locked: true },
|
||||
],
|
||||
target: {
|
||||
build: [
|
||||
{ type: 'oscillator', params: { waveform: 'sawtooth', frequency: 440, detune: -7 } },
|
||||
{ type: 'oscillator', params: { waveform: 'sawtooth', frequency: 440, detune: 7 } },
|
||||
],
|
||||
duration: 2.5,
|
||||
},
|
||||
checks: [
|
||||
{
|
||||
star: 1,
|
||||
name: 'Dos sierras mezcladas',
|
||||
desc: 'Dos osciladores sawtooth conectados al mixer y a la salida',
|
||||
test: (mods, conns) => {
|
||||
const oscs = mods.filter(m => m.type === 'oscillator');
|
||||
const mixer = mods.find(m => m.type === 'mixer');
|
||||
const out = mods.find(m => m.type === 'output');
|
||||
if (oscs.length < 2 || !mixer || !out) return false;
|
||||
const bothSaw = oscs.every(o => o.params.waveform === 'sawtooth');
|
||||
const mixToOut = conns.some(c => c.from.moduleId === mixer.id && c.to.moduleId === out.id);
|
||||
return bothSaw && mixToOut;
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 2,
|
||||
name: 'Con detune',
|
||||
desc: 'Al menos un oscilador tiene detune distinto de 0',
|
||||
test: (mods) => {
|
||||
const oscs = mods.filter(m => m.type === 'oscillator' && m.params.waveform === 'sawtooth');
|
||||
if (oscs.length < 2) return false;
|
||||
const detunes = oscs.map(o => o.params.detune ?? 0);
|
||||
return detunes.some(d => Math.abs(d) > 2);
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 3,
|
||||
name: 'Unísono perfecto',
|
||||
desc: 'Detune entre ±5 y ±15 cents (sutil pero gordo)',
|
||||
test: (mods) => {
|
||||
const oscs = mods.filter(m => m.type === 'oscillator' && m.params.waveform === 'sawtooth');
|
||||
if (oscs.length < 2) return false;
|
||||
const detunes = oscs.map(o => Math.abs(o.params.detune ?? 0)).sort((a, b) => a - b);
|
||||
// One should be near 0 or opposite, total spread should be 5-30 cents
|
||||
const spread = Math.abs(detunes[detunes.length - 1] - detunes[0]);
|
||||
const maxDetune = Math.max(...oscs.map(o => Math.abs(o.params.detune ?? 0)));
|
||||
return maxDetune >= 5 && maxDetune <= 50;
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// ─────────────── LEVEL 1.8: BOSS ───────────────
|
||||
{
|
||||
id: 'w1-8',
|
||||
title: 'Acorde Mayor',
|
||||
subtitle: 'BOSS: Do Mayor',
|
||||
description: 'Pon todo junto. Un acorde mayor se construye con tres notas: la fundamental, la tercera mayor, y la quinta. Do Mayor = C4 (262 Hz) + E4 (330 Hz) + G4 (392 Hz). Constrúyelo.',
|
||||
concept: 'C4=262Hz, E4=330Hz, G4=392Hz. Necesitas 3 osciladores, un mixer para combinarlos, y la salida. Las frecuencias no tienen que ser exactas — usa tu oído para comparar con el sonido objetivo.',
|
||||
availableModules: ['oscillator', 'mixer'],
|
||||
preplacedModules: [
|
||||
{ id: 1, type: 'output', x: 800, y: 160, params: { volume: -6 }, locked: true },
|
||||
],
|
||||
target: {
|
||||
build: [
|
||||
{ type: 'oscillator', params: { waveform: 'sine', frequency: 262 } },
|
||||
{ type: 'oscillator', params: { waveform: 'sine', frequency: 330 } },
|
||||
{ type: 'oscillator', params: { waveform: 'sine', frequency: 392 } },
|
||||
],
|
||||
duration: 3,
|
||||
},
|
||||
checks: [
|
||||
{
|
||||
star: 1,
|
||||
name: 'Tres voces',
|
||||
desc: 'Tres osciladores conectados al mixer y a la salida',
|
||||
test: (mods, conns) => {
|
||||
const oscs = mods.filter(m => m.type === 'oscillator');
|
||||
const mixer = mods.find(m => m.type === 'mixer');
|
||||
const out = mods.find(m => m.type === 'output');
|
||||
if (oscs.length < 3 || !mixer || !out) return false;
|
||||
const mixToOut = conns.some(c => c.from.moduleId === mixer.id && c.to.moduleId === out.id);
|
||||
const count = oscs.filter(o => conns.some(c => c.from.moduleId === o.id && c.to.moduleId === mixer.id)).length;
|
||||
return mixToOut && count >= 3;
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 2,
|
||||
name: 'Acorde cercano',
|
||||
desc: 'Frecuencias cerca de C4, E4, G4 (±30 Hz)',
|
||||
test: (mods) => {
|
||||
const oscs = mods.filter(m => m.type === 'oscillator');
|
||||
if (oscs.length < 3) return false;
|
||||
const freqs = oscs.map(o => o.params.frequency ?? 440).sort((a, b) => a - b);
|
||||
const targets = [262, 330, 392];
|
||||
return targets.every((t, i) => Math.abs(freqs[i] - t) <= 30);
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 3,
|
||||
name: 'Do Mayor perfecto',
|
||||
desc: 'C4=262, E4=330, G4=392 Hz (±10 Hz)',
|
||||
test: (mods) => {
|
||||
const oscs = mods.filter(m => m.type === 'oscillator');
|
||||
if (oscs.length < 3) return false;
|
||||
const freqs = oscs.map(o => o.params.frequency ?? 440).sort((a, b) => a - b);
|
||||
const targets = [262, 330, 392];
|
||||
return targets.every((t, i) => Math.abs(freqs[i] - t) <= 10);
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
576
packages/client/src/game/levels/world10.js
Normal file
576
packages/client/src/game/levels/world10.js
Normal file
@@ -0,0 +1,576 @@
|
||||
/**
|
||||
* World 10 — "Espacio y Stereo" (Space and Stereo)
|
||||
*
|
||||
* Teaches: Stereo imaging, spatial effects, delay for width, reverb placement
|
||||
* 8 levels, boss challenges with complete stereo mix
|
||||
*/
|
||||
|
||||
export const WORLD_10 = {
|
||||
id: 'w10',
|
||||
name: 'Espacio y Stereo',
|
||||
subtitle: 'Profundidad y dimensión',
|
||||
icon: '◉◉',
|
||||
color: '#44ddaa',
|
||||
unlockStars: 108,
|
||||
levels: [
|
||||
// ─────────────── LEVEL 10.1 ───────────────
|
||||
{
|
||||
id: 'w10-1',
|
||||
title: 'Pan Left-Right',
|
||||
subtitle: 'Los canales estéreo básicos',
|
||||
description: 'La estéreo más simple: coloca una fuente en el canal izquierdo y otra en el derecho. El output tiene dos entradas: "left" y "right". Conecta diferentes osciladores a cada uno.',
|
||||
concept: 'Osc 1 → Output (left). Osc 2 → Output (right). El output tiene dos canales separados. Juntos crean la ilusión de width — como si el sonido viniera de dos lugares diferentes.',
|
||||
availableModules: ['oscillator', 'vca', 'envelope', 'keyboard', 'mixer'],
|
||||
preplacedModules: [
|
||||
{ id: 1, type: 'output', x: 800, y: 140, params: { volume: -8 }, locked: true },
|
||||
],
|
||||
target: {
|
||||
build: [
|
||||
{ type: 'oscillator', params: { waveform: 'sine', frequency: 220, detune: 0 } },
|
||||
{ type: 'oscillator', params: { waveform: 'sine', frequency: 330, detune: 0 } },
|
||||
],
|
||||
envelope: { attack: 0.05, decay: 0.3, sustain: 0.4, release: 0.2 },
|
||||
duration: 3,
|
||||
},
|
||||
checks: [
|
||||
{
|
||||
star: 1,
|
||||
name: 'Estéreo básica',
|
||||
desc: 'Dos osciladores, uno al left, uno al right',
|
||||
test: (mods, conns) => {
|
||||
const oscs = mods.filter(m => m.type === 'oscillator');
|
||||
const out = mods.find(m => m.type === 'output');
|
||||
if (oscs.length < 2 || !out) return false;
|
||||
const leftConn = conns.some(c => c.to.moduleId === out.id && c.to.port === 'left');
|
||||
const rightConn = conns.some(c => c.to.moduleId === out.id && c.to.port === 'right');
|
||||
return leftConn && rightConn;
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 2,
|
||||
name: 'Estéreo con VCA',
|
||||
desc: 'Cada oscilador con su VCA antes de output',
|
||||
test: (mods, conns) => {
|
||||
const oscs = mods.filter(m => m.type === 'oscillator');
|
||||
const vcas = mods.filter(m => m.type === 'vca');
|
||||
const out = mods.find(m => m.type === 'output');
|
||||
if (oscs.length < 2 || vcas.length < 2 || !out) return false;
|
||||
const leftConn = conns.some(c => c.to.moduleId === out.id && c.to.port === 'left');
|
||||
const rightConn = conns.some(c => c.to.moduleId === out.id && c.to.port === 'right');
|
||||
return leftConn && rightConn &&
|
||||
oscs.some(o => conns.some(c => c.from.moduleId === o.id && c.to.moduleId === vcas[0].id));
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 3,
|
||||
name: 'Estéreo Controlada',
|
||||
desc: 'Oscs left/right con envelopes separados gateados por keyboard',
|
||||
test: (mods, conns) => {
|
||||
const oscs = mods.filter(m => m.type === 'oscillator');
|
||||
const vcas = mods.filter(m => m.type === 'vca');
|
||||
const envs = mods.filter(m => m.type === 'envelope');
|
||||
const kb = mods.find(m => m.type === 'keyboard');
|
||||
const out = mods.find(m => m.type === 'output');
|
||||
if (oscs.length < 2 || vcas.length < 2 || envs.length < 2 || !kb || !out) return false;
|
||||
const leftConn = conns.some(c => c.to.moduleId === out.id && c.to.port === 'left');
|
||||
const rightConn = conns.some(c => c.to.moduleId === out.id && c.to.port === 'right');
|
||||
const gated = envs.filter(e =>
|
||||
conns.some(c => c.from.moduleId === kb.id && c.to.moduleId === e.id && c.to.port === 'gate')
|
||||
);
|
||||
return leftConn && rightConn && gated.length >= 2;
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// ─────────────── LEVEL 10.2 ───────────────
|
||||
{
|
||||
id: 'w10-2',
|
||||
title: 'Stereo Detune',
|
||||
subtitle: 'Ancho con osciladores diferentes',
|
||||
description: 'Coloca el mismo oscilador en ambos canales pero detuned: izquierda a la frecuencia exacta, derecha con un pequeño detune (+5 a +15 cents). Crea un "chorus" natural que te envuelve.',
|
||||
concept: 'Osc 1 (detune 0) → Left. Osc 2 (detune +7) a misma nota → Right. Cuando están cerca pero no iguales, el beating crea width. Es como tener dos cantantes cantando casi al unísono.',
|
||||
availableModules: ['oscillator', 'vca', 'envelope', 'keyboard'],
|
||||
preplacedModules: [
|
||||
{ id: 1, type: 'output', x: 800, y: 140, params: { volume: -8 }, locked: true },
|
||||
],
|
||||
target: {
|
||||
build: [
|
||||
{ type: 'oscillator', params: { waveform: 'sine', frequency: 220, detune: 0 } },
|
||||
{ type: 'oscillator', params: { waveform: 'sine', frequency: 220, detune: 8 } },
|
||||
],
|
||||
envelope: { attack: 0.06, decay: 0.35, sustain: 0.35, release: 0.25 },
|
||||
duration: 3,
|
||||
},
|
||||
checks: [
|
||||
{
|
||||
star: 1,
|
||||
name: 'Dos osciladores detuned',
|
||||
desc: 'Oscs a misma frecuencia pero con detune diferente',
|
||||
test: (mods, conns) => {
|
||||
const oscs = mods.filter(m => m.type === 'oscillator');
|
||||
const out = mods.find(m => m.type === 'output');
|
||||
if (oscs.length < 2 || !out) return false;
|
||||
const detunes = oscs.map(o => o.params.detune ?? 0);
|
||||
const freqs = oscs.map(o => o.params.frequency ?? 440);
|
||||
const sameFreq = Math.abs(freqs[0] - freqs[1]) < 10;
|
||||
const differentDetune = Math.abs(detunes[0] - detunes[1]) > 3;
|
||||
return sameFreq && differentDetune;
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 2,
|
||||
name: 'Stereo width audible',
|
||||
desc: 'Detune entre oscs > 5 cents para efecto chorus',
|
||||
test: (mods, conns) => {
|
||||
const oscs = mods.filter(m => m.type === 'oscillator');
|
||||
if (oscs.length < 2) return false;
|
||||
const detunes = oscs.map(o => o.params.detune ?? 0);
|
||||
return Math.abs(detunes[0] - detunes[1]) > 5;
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 3,
|
||||
name: 'Chorus Estéreo',
|
||||
desc: 'Detuned oscs left/right con VCAs y envelopes',
|
||||
test: (mods, conns) => {
|
||||
const oscs = mods.filter(m => m.type === 'oscillator');
|
||||
const vcas = mods.filter(m => m.type === 'vca');
|
||||
const envs = mods.filter(m => m.type === 'envelope');
|
||||
const out = mods.find(m => m.type === 'output');
|
||||
if (oscs.length < 2 || vcas.length < 2 || envs.length < 1 || !out) return false;
|
||||
const detunes = oscs.map(o => o.params.detune ?? 0);
|
||||
const freqs = oscs.map(o => o.params.frequency ?? 440);
|
||||
const leftConn = conns.some(c => c.to.moduleId === out.id && c.to.port === 'left');
|
||||
const rightConn = conns.some(c => c.to.moduleId === out.id && c.to.port === 'right');
|
||||
return Math.abs(freqs[0] - freqs[1]) < 10 &&
|
||||
Math.abs(detunes[0] - detunes[1]) > 5 &&
|
||||
leftConn && rightConn;
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// ─────────────── LEVEL 10.3 ───────────────
|
||||
{
|
||||
id: 'w10-3',
|
||||
title: 'Delay para Ancho',
|
||||
subtitle: 'La profundidad del eco',
|
||||
description: 'El delay es uno de los mejores trucos para width: copia la señal, la envía al otro canal con un pequeño delay (20-80ms). El cerebro interpreta esto como "la misma fuente reflejada en espacio".',
|
||||
concept: 'Osc → Left (seco). Osc → Delay (15-50ms) → Right. El delay crea la ilusión de distancia. Cuanto más delay, más separación. Mantén el feedback bajo para evitar caos.',
|
||||
availableModules: ['oscillator', 'vca', 'delay', 'envelope', 'keyboard'],
|
||||
preplacedModules: [
|
||||
{ id: 1, type: 'output', x: 800, y: 140, params: { volume: -8 }, locked: true },
|
||||
],
|
||||
target: {
|
||||
build: [
|
||||
{ type: 'oscillator', params: { waveform: 'sine', frequency: 165, detune: 0 } },
|
||||
],
|
||||
effects: [
|
||||
{ type: 'delay', delayTime: 0.035, feedback: 0.15, wet: 0.8 },
|
||||
],
|
||||
envelope: { attack: 0.08, decay: 0.4, sustain: 0.3, release: 0.3 },
|
||||
duration: 3,
|
||||
},
|
||||
checks: [
|
||||
{
|
||||
star: 1,
|
||||
name: 'Delay en señal',
|
||||
desc: 'Oscilador → Delay → Output',
|
||||
test: (mods, conns) => {
|
||||
const osc = mods.find(m => m.type === 'oscillator');
|
||||
const del = mods.find(m => m.type === 'delay');
|
||||
const out = mods.find(m => m.type === 'output');
|
||||
if (!osc || !del || !out) return false;
|
||||
return conns.some(c => c.from.moduleId === osc.id && c.to.moduleId === del.id) &&
|
||||
conns.some(c => c.from.moduleId === del.id && c.to.moduleId === out.id);
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 2,
|
||||
name: 'Delay corto',
|
||||
desc: 'Delay con tiempo entre 20-80ms',
|
||||
test: (mods) => {
|
||||
const del = mods.find(m => m.type === 'delay');
|
||||
if (!del) return false;
|
||||
const time = del.params.delayTime ?? 0.3;
|
||||
return time >= 0.02 && time <= 0.08;
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 3,
|
||||
name: 'Delay Estéreo',
|
||||
desc: 'Osc left + Osc/Delay right con envelopes',
|
||||
test: (mods, conns) => {
|
||||
const oscs = mods.filter(m => m.type === 'oscillator');
|
||||
const del = mods.find(m => m.type === 'delay');
|
||||
const out = mods.find(m => m.type === 'output');
|
||||
if (oscs.length < 1 || !del || !out) return false;
|
||||
const time = del.params.delayTime ?? 0.3;
|
||||
const leftConn = conns.some(c => c.to.moduleId === out.id && c.to.port === 'left');
|
||||
const rightConn = conns.some(c => c.from.moduleId === del.id && c.to.moduleId === out.id && c.to.port === 'right');
|
||||
return time >= 0.015 && time <= 0.1 &&
|
||||
(del.params.feedback ?? 0.4) < 0.5 &&
|
||||
leftConn && rightConn;
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// ─────────────── LEVEL 10.4 ───────────────
|
||||
{
|
||||
id: 'w10-4',
|
||||
title: 'Reverb Corta',
|
||||
subtitle: 'La sala pequeña',
|
||||
description: 'Una reverb corta (decay 1-2s) simula una habitación pequeña. No es mucha cola, solo lo suficiente para darle "espacio" al sonido sin que desaparezca en la distancia. Perfecto para síntesis.',
|
||||
concept: 'Osc → VCA → Reverb (decay 1-2s, wet 0.3-0.5) → Output. La reverb enturbia ligeramente el sonido y lo coloca "en una sala". Mantén wet bajo para que no sea un sonido amortiguado.',
|
||||
availableModules: ['oscillator', 'vca', 'reverb', 'envelope', 'keyboard'],
|
||||
preplacedModules: [
|
||||
{ id: 1, type: 'output', x: 700, y: 120, params: { volume: -8 }, locked: true },
|
||||
],
|
||||
target: {
|
||||
build: [
|
||||
{ type: 'oscillator', params: { waveform: 'sine', frequency: 262, detune: 0 } },
|
||||
],
|
||||
effects: [
|
||||
{ type: 'reverb', decay: 1.5, wet: 0.4 },
|
||||
],
|
||||
envelope: { attack: 0.07, decay: 0.4, sustain: 0.35, release: 0.25 },
|
||||
duration: 3,
|
||||
},
|
||||
checks: [
|
||||
{
|
||||
star: 1,
|
||||
name: 'Reverb en la cadena',
|
||||
desc: 'Osc → Reverb → Output',
|
||||
test: (mods, conns) => {
|
||||
const osc = mods.find(m => m.type === 'oscillator');
|
||||
const rev = mods.find(m => m.type === 'reverb');
|
||||
const out = mods.find(m => m.type === 'output');
|
||||
if (!osc || !rev || !out) return false;
|
||||
return conns.some(c => c.from.moduleId === osc.id && c.to.moduleId === rev.id) &&
|
||||
conns.some(c => c.from.moduleId === rev.id && c.to.moduleId === out.id);
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 2,
|
||||
name: 'Decay corta',
|
||||
desc: 'Reverb con decay entre 1-2 segundos',
|
||||
test: (mods) => {
|
||||
const rev = mods.find(m => m.type === 'reverb');
|
||||
if (!rev) return false;
|
||||
const decay = rev.params.decay ?? 3;
|
||||
return decay >= 1 && decay <= 2;
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 3,
|
||||
name: 'Sala Perfecta',
|
||||
desc: 'Reverb (decay 1-2s, wet 0.3-0.5) + envelope al VCA',
|
||||
test: (mods, conns) => {
|
||||
const osc = mods.find(m => m.type === 'oscillator');
|
||||
const vca = mods.find(m => m.type === 'vca');
|
||||
const rev = mods.find(m => m.type === 'reverb');
|
||||
const env = mods.find(m => m.type === 'envelope');
|
||||
if (!osc || !vca || !rev || !env) return false;
|
||||
const decay = rev.params.decay ?? 3;
|
||||
const wet = rev.params.wet ?? 0.4;
|
||||
return decay >= 1 && decay <= 2 &&
|
||||
wet >= 0.25 && wet <= 0.6 &&
|
||||
conns.some(c => c.from.moduleId === env.id && c.to.moduleId === vca.id && c.to.port === 'cv');
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// ─────────────── LEVEL 10.5 ───────────────
|
||||
{
|
||||
id: 'w10-5',
|
||||
title: 'Catedral Reverb',
|
||||
subtitle: 'Los espacios enormes',
|
||||
description: 'Una catedral reverb es lo opuesto: decay largo (3+ segundos), wet alto. El sonido se desvanece lentamente, como si estuvieras en una basílica gigante. Crea atmósfera épica.',
|
||||
concept: 'Osc → VCA → Reverb (decay > 3s, wet > 0.5) → Output. El sonido se desmorona lentamente en el aire. Usa notas largas para aprovechar la cola reverb. ¡Es mágico!',
|
||||
availableModules: ['oscillator', 'vca', 'reverb', 'envelope', 'keyboard'],
|
||||
preplacedModules: [
|
||||
{ id: 1, type: 'output', x: 700, y: 120, params: { volume: -8 }, locked: true },
|
||||
],
|
||||
target: {
|
||||
build: [
|
||||
{ type: 'oscillator', params: { waveform: 'sine', frequency: 196, detune: 0 } },
|
||||
],
|
||||
effects: [
|
||||
{ type: 'reverb', decay: 4.2, wet: 0.65 },
|
||||
],
|
||||
envelope: { attack: 0.06, decay: 0.8, sustain: 0.4, release: 0.5 },
|
||||
duration: 4,
|
||||
},
|
||||
checks: [
|
||||
{
|
||||
star: 1,
|
||||
name: 'Reverb larga',
|
||||
desc: 'Reverb con decay > 3 segundos',
|
||||
test: (mods) => {
|
||||
const rev = mods.find(m => m.type === 'reverb');
|
||||
if (!rev) return false;
|
||||
return (rev.params.decay ?? 3) > 3;
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 2,
|
||||
name: 'Reverb mojada',
|
||||
desc: 'Reverb con wet > 0.5 para efecto dramático',
|
||||
test: (mods) => {
|
||||
const rev = mods.find(m => m.type === 'reverb');
|
||||
if (!rev) return false;
|
||||
return (rev.params.decay ?? 3) > 3 &&
|
||||
(rev.params.wet ?? 0.4) > 0.5;
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 3,
|
||||
name: 'Catedral Épica',
|
||||
desc: 'Reverb (decay > 4s, wet > 0.6) con envelope lento al VCA',
|
||||
test: (mods, conns) => {
|
||||
const osc = mods.find(m => m.type === 'oscillator');
|
||||
const vca = mods.find(m => m.type === 'vca');
|
||||
const rev = mods.find(m => m.type === 'reverb');
|
||||
const env = mods.find(m => m.type === 'envelope');
|
||||
const kb = mods.find(m => m.type === 'keyboard');
|
||||
if (!osc || !vca || !rev || !env || !kb) return false;
|
||||
return (rev.params.decay ?? 3) > 4 &&
|
||||
(rev.params.wet ?? 0.4) > 0.6 &&
|
||||
(env.params.attack ?? 0.01) < 0.1 &&
|
||||
(env.params.decay ?? 0.2) > 0.5 &&
|
||||
conns.some(c => c.from.moduleId === kb.id && c.to.moduleId === env.id && c.to.port === 'gate');
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// ─────────────── LEVEL 10.6 ───────────────
|
||||
{
|
||||
id: 'w10-6',
|
||||
title: 'Slapback Echo',
|
||||
subtitle: 'Doblado rítmico',
|
||||
description: 'El slapback echo es un delay muy corto (100-200ms) sin feedback, que crea un efecto de "doblado" — como si hubiera una copia del sonido muy cerca. Popular en rockabilly y sintetizadores.',
|
||||
concept: 'Osc → Left (seco). Osc → Delay (100-200ms, feedback bajo) → Right. El delay corto mantiene la segunda "voz" identificable pero cercana. Es como tener un doblante.',
|
||||
availableModules: ['oscillator', 'vca', 'delay', 'envelope', 'keyboard'],
|
||||
preplacedModules: [
|
||||
{ id: 1, type: 'output', x: 800, y: 140, params: { volume: -8 }, locked: true },
|
||||
],
|
||||
target: {
|
||||
build: [
|
||||
{ type: 'oscillator', params: { waveform: 'square', frequency: 220, detune: 0 } },
|
||||
],
|
||||
effects: [
|
||||
{ type: 'delay', delayTime: 0.15, feedback: 0.08, wet: 0.75 },
|
||||
],
|
||||
envelope: { attack: 0.05, decay: 0.35, sustain: 0.4, release: 0.2 },
|
||||
duration: 3,
|
||||
},
|
||||
checks: [
|
||||
{
|
||||
star: 1,
|
||||
name: 'Delay rítmico',
|
||||
desc: 'Delay entre 80-250ms',
|
||||
test: (mods) => {
|
||||
const del = mods.find(m => m.type === 'delay');
|
||||
if (!del) return false;
|
||||
const time = del.params.delayTime ?? 0.3;
|
||||
return time >= 0.08 && time <= 0.25;
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 2,
|
||||
name: 'Sin feedback',
|
||||
desc: 'Delay con feedback < 0.2 para no crear repeticiones caóticas',
|
||||
test: (mods) => {
|
||||
const del = mods.find(m => m.type === 'delay');
|
||||
if (!del) return false;
|
||||
const time = del.params.delayTime ?? 0.3;
|
||||
const fb = del.params.feedback ?? 0.4;
|
||||
return time >= 0.08 && time <= 0.25 && fb < 0.2;
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 3,
|
||||
name: 'Doblante Perfecto',
|
||||
desc: 'Delay (100-200ms, feedback < 0.1) en stereo left/right',
|
||||
test: (mods, conns) => {
|
||||
const osc = mods.find(m => m.type === 'oscillator');
|
||||
const del = mods.find(m => m.type === 'delay');
|
||||
const out = mods.find(m => m.type === 'output');
|
||||
if (!osc || !del || !out) return false;
|
||||
const time = del.params.delayTime ?? 0.3;
|
||||
const fb = del.params.feedback ?? 0.4;
|
||||
const leftConn = conns.some(c => c.to.moduleId === out.id && c.to.port === 'left');
|
||||
const rightConn = conns.some(c => c.from.moduleId === del.id && c.to.moduleId === out.id && c.to.port === 'right');
|
||||
return time >= 0.1 && time <= 0.2 &&
|
||||
fb < 0.1 &&
|
||||
leftConn && rightConn;
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// ─────────────── LEVEL 10.7 ───────────────
|
||||
{
|
||||
id: 'w10-7',
|
||||
title: 'Orden de Efectos',
|
||||
subtitle: 'La cadena de procesamiento',
|
||||
description: 'El orden de los efectos es crítico: ¿delay antes o después de reverb? ¿Filtro antes que distortion? Aquí aprendes a construir cadenas de efectos que suenen coherentes y profesionales.',
|
||||
concept: 'Construye: Osc → Filter → Distortion → Delay → Reverb → Output. Cada efecto transforma el anterior. El filtro quita brillo, distortion añade armónicos, delay añade movimiento, reverb añade espacio.',
|
||||
availableModules: ['oscillator', 'filter', 'distortion', 'delay', 'reverb', 'vca', 'envelope', 'keyboard'],
|
||||
preplacedModules: [
|
||||
{ id: 1, type: 'output', x: 900, y: 140, params: { volume: -8 }, locked: true },
|
||||
],
|
||||
target: {
|
||||
build: [
|
||||
{ type: 'oscillator', params: { waveform: 'sawtooth', frequency: 280, detune: 0 } },
|
||||
],
|
||||
filter: { type: 'lowpass', frequency: 3500, Q: 1.5 },
|
||||
effects: [
|
||||
{ type: 'distortion', distortion: 0.45, wet: 0.5 },
|
||||
{ type: 'delay', delayTime: 0.3, feedback: 0.35, wet: 0.55 },
|
||||
{ type: 'reverb', decay: 2.2, wet: 0.45 },
|
||||
],
|
||||
envelope: { attack: 0.08, decay: 0.45, sustain: 0.25, release: 0.3 },
|
||||
duration: 4,
|
||||
},
|
||||
checks: [
|
||||
{
|
||||
star: 1,
|
||||
name: 'Cadena básica',
|
||||
desc: 'Osc → Filter → Delay → Reverb → Output',
|
||||
test: (mods, conns) => {
|
||||
const osc = mods.find(m => m.type === 'oscillator');
|
||||
const flt = mods.find(m => m.type === 'filter');
|
||||
const del = mods.find(m => m.type === 'delay');
|
||||
const rev = mods.find(m => m.type === 'reverb');
|
||||
const out = mods.find(m => m.type === 'output');
|
||||
if (!osc || !flt || !del || !rev || !out) return false;
|
||||
return conns.some(c => c.from.moduleId === osc.id && c.to.moduleId === flt.id) &&
|
||||
conns.some(c => c.from.moduleId === flt.id && c.to.moduleId === del.id) &&
|
||||
conns.some(c => c.from.moduleId === del.id && c.to.moduleId === rev.id) &&
|
||||
conns.some(c => c.from.moduleId === rev.id && c.to.moduleId === out.id);
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 2,
|
||||
name: 'Con distortion',
|
||||
desc: 'Cadena con filtro + distortion + delay + reverb',
|
||||
test: (mods, conns) => {
|
||||
const flt = mods.find(m => m.type === 'filter');
|
||||
const dist = mods.find(m => m.type === 'distortion');
|
||||
const del = mods.find(m => m.type === 'delay');
|
||||
const rev = mods.find(m => m.type === 'reverb');
|
||||
if (!flt || !dist || !del || !rev) return false;
|
||||
return conns.some(c => c.from.moduleId === dist.id && c.to.moduleId === del.id) ||
|
||||
conns.some(c => c.from.moduleId === dist.id && c.to.moduleId === rev.id);
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 3,
|
||||
name: 'Cadena Profesional',
|
||||
desc: 'Osc → Filter → Distortion → Delay → Reverb con envelope',
|
||||
test: (mods, conns) => {
|
||||
const osc = mods.find(m => m.type === 'oscillator');
|
||||
const flt = mods.find(m => m.type === 'filter');
|
||||
const dist = mods.find(m => m.type === 'distortion');
|
||||
const del = mods.find(m => m.type === 'delay');
|
||||
const rev = mods.find(m => m.type === 'reverb');
|
||||
const env = mods.find(m => m.type === 'envelope');
|
||||
const out = mods.find(m => m.type === 'output');
|
||||
if (!osc || !flt || !dist || !del || !rev || !env || !out) return false;
|
||||
const fltOsc = conns.some(c => c.from.moduleId === osc.id && c.to.moduleId === flt.id);
|
||||
const distFlt = conns.some(c => c.from.moduleId === flt.id && c.to.moduleId === dist.id);
|
||||
const delDist = conns.some(c => c.from.moduleId === dist.id && c.to.moduleId === del.id);
|
||||
const revDel = conns.some(c => c.from.moduleId === del.id && c.to.moduleId === rev.id);
|
||||
const outRev = conns.some(c => c.from.moduleId === rev.id && c.to.moduleId === out.id);
|
||||
return fltOsc && distFlt && delDist && revDel && outRev;
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// ─────────────── LEVEL 10.8: BOSS ───────────────
|
||||
{
|
||||
id: 'w10-8',
|
||||
title: 'Mix Espacial',
|
||||
subtitle: 'BOSS FINAL: Orquesta Estéreo',
|
||||
description: 'Construye una mezcla estéreo completa con múltiples fuentes, cada una con su propia posición en el espacio. Usa delay, reverb, y pan para colocar cada instrumento. Crea una orquesta de sintetizadores.',
|
||||
concept: 'Múltiples osciladores/fuentes, algunos en left/right, algunos con delay, algunos con reverb, todos controlados por keyboard/sequencer. La mezcla final debe sonar amplia, profunda, y multidimensional.',
|
||||
availableModules: ['oscillator', 'filter', 'vca', 'mixer', 'lfo', 'envelope', 'keyboard', 'sequencer', 'delay', 'reverb', 'distortion'],
|
||||
preplacedModules: [
|
||||
{ id: 1, type: 'output', x: 950, y: 160, params: { volume: -8 }, locked: true },
|
||||
],
|
||||
target: {
|
||||
build: [
|
||||
{ type: 'oscillator', params: { waveform: 'sine', frequency: 110, detune: 0 } },
|
||||
{ type: 'oscillator', params: { waveform: 'sine', frequency: 110, detune: 10 } },
|
||||
{ type: 'oscillator', params: { waveform: 'sawtooth', frequency: 220, detune: 0 } },
|
||||
],
|
||||
filter: { type: 'lowpass', frequency: 4000, Q: 1.3 },
|
||||
lfo: { frequency: 0.6, type: 'sine', min: 2000, max: 5000, target: 'frequency' },
|
||||
effects: [
|
||||
{ type: 'delay', delayTime: 0.25, feedback: 0.4, wet: 0.6 },
|
||||
{ type: 'reverb', decay: 3, wet: 0.55 },
|
||||
],
|
||||
envelope: { attack: 0.1, decay: 0.5, sustain: 0.3, release: 0.4 },
|
||||
duration: 5,
|
||||
},
|
||||
checks: [
|
||||
{
|
||||
star: 1,
|
||||
name: 'Mezcla funcional',
|
||||
desc: 'Múltiples fuentes en left y right del output',
|
||||
test: (mods, conns) => {
|
||||
const oscs = mods.filter(m => m.type === 'oscillator');
|
||||
const out = mods.find(m => m.type === 'output');
|
||||
if (oscs.length < 2 || !out) return false;
|
||||
const leftConn = conns.some(c => c.to.moduleId === out.id && c.to.port === 'left');
|
||||
const rightConn = conns.some(c => c.to.moduleId === out.id && c.to.port === 'right');
|
||||
return leftConn && rightConn && conns.length >= 8;
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 2,
|
||||
name: 'Con efectos espaciales',
|
||||
desc: 'Delay y Reverb en la mezcla creando profundidad',
|
||||
test: (mods, conns) => {
|
||||
const oscs = mods.filter(m => m.type === 'oscillator');
|
||||
const del = mods.find(m => m.type === 'delay');
|
||||
const rev = mods.find(m => m.type === 'reverb');
|
||||
const out = mods.find(m => m.type === 'output');
|
||||
if (oscs.length < 2 || !del || !rev || !out) return false;
|
||||
const delToOut = conns.some(c => c.from.moduleId === del.id && c.to.moduleId === out.id);
|
||||
const revToOut = conns.some(c => c.from.moduleId === rev.id && c.to.moduleId === out.id);
|
||||
return delToOut && revToOut;
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 3,
|
||||
name: 'Orquesta Completa',
|
||||
desc: '3+ oscs, stereo pan, delay + reverb, filter, envelope, keyboard/sequencer',
|
||||
test: (mods, conns) => {
|
||||
const oscs = mods.filter(m => m.type === 'oscillator');
|
||||
const flts = mods.filter(m => m.type === 'filter');
|
||||
const envs = mods.filter(m => m.type === 'envelope');
|
||||
const del = mods.find(m => m.type === 'delay');
|
||||
const rev = mods.find(m => m.type === 'reverb');
|
||||
const kb = mods.find(m => m.type === 'keyboard');
|
||||
const seq = mods.find(m => m.type === 'sequencer');
|
||||
const out = mods.find(m => m.type === 'output');
|
||||
if (oscs.length < 3 || flts.length < 1 || envs.length < 1 || !del || !rev || !out) return false;
|
||||
if (!kb && !seq) return false;
|
||||
const leftConn = conns.some(c => c.to.moduleId === out.id && c.to.port === 'left');
|
||||
const rightConn = conns.some(c => c.to.moduleId === out.id && c.to.port === 'right');
|
||||
const delToOut = conns.some(c => c.from.moduleId === del.id && c.to.moduleId === out.id);
|
||||
const revToOut = conns.some(c => c.from.moduleId === rev.id && c.to.moduleId === out.id);
|
||||
return leftConn && rightConn && delToOut && revToOut && conns.length >= 15;
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
596
packages/client/src/game/levels/world11.js
Normal file
596
packages/client/src/game/levels/world11.js
Normal file
@@ -0,0 +1,596 @@
|
||||
/**
|
||||
* World 11 — "Técnicas Avanzadas" (Advanced Techniques)
|
||||
*
|
||||
* Teaches: filter self-oscillation, ring modulation, drone textures,
|
||||
* polysynth, sidechain, feedback loops, cross-modulation
|
||||
* 8 levels, boss challenge with experimental patching
|
||||
*/
|
||||
|
||||
export const WORLD_11 = {
|
||||
id: 'w11',
|
||||
name: 'Técnicas Avanzadas',
|
||||
subtitle: 'Dominando el sintetizador',
|
||||
icon: '⚙',
|
||||
color: '#aa55ff',
|
||||
unlockStars: 120,
|
||||
levels: [
|
||||
// ─────────────── LEVEL 11.1 ───────────────
|
||||
{
|
||||
id: 'w11-1',
|
||||
title: 'Oscilación del Filtro',
|
||||
subtitle: 'El filtro se vuelve oscilador',
|
||||
description: 'Cuando subes la resonancia (Q) de un filtro lowpass al máximo, el filtro se auto-oscila y produce un tono puro. Es como un oscilador oculto dentro del filtro. Al modular la frecuencia de corte, obtienes un sintetizador completamente nuevo.',
|
||||
concept: 'Noise → Filter LP con Q muy alto (>8) → VCA → Output. Envelope al VCA. LFO o Keyboard al cutoff del filtro. La oscilación del filtro crea tonos puros sin necesidad de oscilador.',
|
||||
availableModules: ['noise', 'filter', 'vca', 'envelope', 'lfo', 'keyboard'],
|
||||
preplacedModules: [
|
||||
{ id: 1, type: 'output', x: 700, y: 120, params: { volume: -6 }, locked: true },
|
||||
],
|
||||
target: {
|
||||
build: [
|
||||
{ type: 'noise', params: { type: 'white' } },
|
||||
],
|
||||
filter: { type: 'lowpass', frequency: 1500, Q: 9.5 },
|
||||
lfo: { frequency: 1, type: 'sine', min: 600, max: 3500, target: 'frequency' },
|
||||
envelope: { attack: 0.1, decay: 0.4, sustain: 0.3, release: 0.25 },
|
||||
duration: 3,
|
||||
},
|
||||
checks: [
|
||||
{
|
||||
star: 1,
|
||||
name: 'Filtro resonante',
|
||||
desc: 'Noise → Filter LP con Q alto (>5) → VCA → Output',
|
||||
test: (mods, conns) => {
|
||||
const noise = mods.find(m => m.type === 'noise');
|
||||
const flt = mods.find(m => m.type === 'filter');
|
||||
const vca = mods.find(m => m.type === 'vca');
|
||||
const out = mods.find(m => m.type === 'output');
|
||||
if (!noise || !flt || !vca || !out) return false;
|
||||
return flt.params.type === 'lowpass' && (flt.params.Q ?? 1) > 5 &&
|
||||
conns.some(c => c.from.moduleId === noise.id && c.to.moduleId === flt.id);
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 2,
|
||||
name: 'Auto-oscilación',
|
||||
desc: 'Filtro con Q > 8 para oscilación clara',
|
||||
test: (mods) => {
|
||||
const flt = mods.find(m => m.type === 'filter');
|
||||
return flt && flt.params.type === 'lowpass' && (flt.params.Q ?? 1) > 8;
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 3,
|
||||
name: 'Sintetizador por Filtro',
|
||||
desc: 'Q > 9, LFO o Keyboard al cutoff, envelope al VCA',
|
||||
test: (mods, conns) => {
|
||||
const flt = mods.find(m => m.type === 'filter');
|
||||
const env = mods.find(m => m.type === 'envelope');
|
||||
const lfo = mods.find(m => m.type === 'lfo');
|
||||
const kb = mods.find(m => m.type === 'keyboard');
|
||||
if (!flt || !env) return false;
|
||||
const hasModulation = (lfo && conns.some(c => c.from.moduleId === lfo.id && c.to.moduleId === flt.id && c.to.port === 'cutoff')) ||
|
||||
(kb && conns.some(c => c.from.moduleId === kb.id && c.to.moduleId === flt.id && c.to.port === 'cutoff'));
|
||||
return (flt.params.Q ?? 1) > 9 && hasModulation &&
|
||||
conns.some(c => c.from.moduleId === env.id && c.to.port === 'cv');
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// ─────────────── LEVEL 11.2 ───────────────
|
||||
{
|
||||
id: 'w11-2',
|
||||
title: 'Modulación en Anillo',
|
||||
subtitle: 'VCA como multiplicador',
|
||||
description: 'La modulación en anillo es un efecto clásico que surge de multiplicar dos señales de audio. Se simula aquí usando un VCA: una señal en "in" y un LFO/oscilador rápido en "cv". El resultado son frecuencias de suma y resta (sidebands).',
|
||||
concept: 'Osc1 → VCA. Osc2 rápido o LFO → cv del VCA. VCA → Mixer o directamente a Output. El VCA actúa como "multiplicador" creando tonos nuevos inarmónicos.',
|
||||
availableModules: ['oscillator', 'lfo', 'vca', 'mixer', 'filter', 'keyboard'],
|
||||
preplacedModules: [
|
||||
{ id: 1, type: 'output', x: 700, y: 120, params: { volume: -8 }, locked: true },
|
||||
],
|
||||
target: {
|
||||
build: [
|
||||
{ type: 'oscillator', params: { waveform: 'sine', frequency: 200, detune: 0 } },
|
||||
{ type: 'oscillator', params: { waveform: 'sine', frequency: 8, detune: 0 } },
|
||||
],
|
||||
envelope: { attack: 0.08, decay: 0.35, sustain: 0.35, release: 0.2 },
|
||||
duration: 3,
|
||||
},
|
||||
checks: [
|
||||
{
|
||||
star: 1,
|
||||
name: 'Dos osciladores',
|
||||
desc: 'Osc1 al in del VCA, Osc2/LFO al cv',
|
||||
test: (mods, conns) => {
|
||||
const oscs = mods.filter(m => m.type === 'oscillator');
|
||||
const lfo = mods.find(m => m.type === 'lfo');
|
||||
const vca = mods.find(m => m.type === 'vca');
|
||||
if (oscs.length < 1 || !vca || (!lfo && oscs.length < 2)) return false;
|
||||
const hasInput = oscs.some(o => conns.some(c => c.from.moduleId === o.id && c.to.moduleId === vca.id && c.to.port === 'in'));
|
||||
const hasCV = (lfo && conns.some(c => c.from.moduleId === lfo.id && c.to.moduleId === vca.id && c.to.port === 'cv')) ||
|
||||
(oscs.length >= 2 && oscs.some(o => conns.some(c => c.from.moduleId === o.id && c.to.moduleId === vca.id && c.to.port === 'cv')));
|
||||
return hasInput && hasCV;
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 2,
|
||||
name: 'Sonido ruidoso',
|
||||
desc: 'LFO/Osc rápido modulando el VCA (frecuencias inarmónicas)',
|
||||
test: (mods, conns) => {
|
||||
const vca = mods.find(m => m.type === 'vca');
|
||||
const lfo = mods.find(m => m.type === 'lfo');
|
||||
const oscs = mods.filter(m => m.type === 'oscillator');
|
||||
if (!vca) return false;
|
||||
const hasRingMod = (lfo && conns.some(c => c.from.moduleId === lfo.id && c.to.moduleId === vca.id && c.to.port === 'cv')) ||
|
||||
(oscs.length >= 2);
|
||||
return hasRingMod && conns.some(c => c.to.moduleId === vca.id && c.to.port === 'in');
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 3,
|
||||
name: 'Modulación en Anillo completa',
|
||||
desc: 'Dos oscs con frecuencias diferentes, VCA como ring mod, sonidos inarmónicos claros',
|
||||
test: (mods, conns) => {
|
||||
const oscs = mods.filter(m => m.type === 'oscillator');
|
||||
const vca = mods.find(m => m.type === 'vca');
|
||||
if (oscs.length < 2 || !vca) return false;
|
||||
const hasInput = oscs.some(o => conns.some(c => c.from.moduleId === o.id && c.to.moduleId === vca.id && c.to.port === 'in'));
|
||||
const hasCV = oscs.some(o => conns.some(c => c.from.moduleId === o.id && c.to.moduleId === vca.id && c.to.port === 'cv'));
|
||||
const freq1 = oscs[0].params.frequency ?? 440;
|
||||
const freq2 = oscs[1].params.frequency ?? 440;
|
||||
return hasInput && hasCV && Math.abs(freq1 - freq2) > 50;
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// ─────────────── LEVEL 11.3 ───────────────
|
||||
{
|
||||
id: 'w11-3',
|
||||
title: 'Texturas de Drone',
|
||||
subtitle: 'Sonido que evoluciona lentamente',
|
||||
description: 'Un drone es un sonido constante que evoluciona gradualmente. Se crea con osciladores a tonos bajos, múltiples LFOs muy lentos modulando filtros y amplitud, creando texturas hipnóticas que cambian imperceptiblemente.',
|
||||
concept: 'Dos oscs sine bajos (~50-100 Hz) detuned. Mixer → Filter LP. LFOs muy lentos (~0.1-0.5 Hz) al cutoff, amplitud. Reverb largo. Sin gates ni envelopes percusivos — todo fluye continuamente.',
|
||||
availableModules: ['oscillator', 'filter', 'lfo', 'mixer', 'reverb', 'vca'],
|
||||
preplacedModules: [
|
||||
{ id: 1, type: 'output', x: 800, y: 140, params: { volume: -8 }, locked: true },
|
||||
],
|
||||
target: {
|
||||
build: [
|
||||
{ type: 'oscillator', params: { waveform: 'sine', frequency: 60, detune: 0 } },
|
||||
{ type: 'oscillator', params: { waveform: 'sine', frequency: 60, detune: -4 } },
|
||||
],
|
||||
filter: { type: 'lowpass', frequency: 2500, Q: 0.85 },
|
||||
lfo: [
|
||||
{ frequency: 0.3, type: 'sine', min: 1000, max: 4000, target: 'frequency' },
|
||||
{ frequency: 0.15, type: 'sine', min: 0.3, max: 0.9, target: 'amplitude' },
|
||||
],
|
||||
effects: [
|
||||
{ type: 'reverb', decay: 5, wet: 0.7 },
|
||||
],
|
||||
duration: 5,
|
||||
},
|
||||
checks: [
|
||||
{
|
||||
star: 1,
|
||||
name: 'Osciladores graves',
|
||||
desc: 'Dos oscs sine < 120 Hz mezclados',
|
||||
test: (mods) => {
|
||||
const oscs = mods.filter(m => m.type === 'oscillator' && m.params.waveform === 'sine');
|
||||
if (oscs.length < 2) return false;
|
||||
return oscs.filter(o => (o.params.frequency ?? 440) < 120).length >= 2;
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 2,
|
||||
name: 'Evolución lenta',
|
||||
desc: 'LFO lento (<1 Hz) modulando el filtro',
|
||||
test: (mods, conns) => {
|
||||
const lfo = mods.find(m => m.type === 'lfo');
|
||||
const flt = mods.find(m => m.type === 'filter');
|
||||
if (!lfo || !flt) return false;
|
||||
const isLowFreq = (lfo.params.frequency ?? 2) < 1;
|
||||
const toFilter = conns.some(c => c.from.moduleId === lfo.id && c.to.moduleId === flt.id && c.to.port === 'cutoff');
|
||||
return isLowFreq && toFilter;
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 3,
|
||||
name: 'Drone hipnótico',
|
||||
desc: '2 oscs sine detuned bajos, filtro LP, 2+ LFOs muy lentos, reverb largo',
|
||||
test: (mods, conns) => {
|
||||
const oscs = mods.filter(m => m.type === 'oscillator' && m.params.waveform === 'sine');
|
||||
const lfos = mods.filter(m => m.type === 'lfo');
|
||||
const flt = mods.find(m => m.type === 'filter');
|
||||
const rev = mods.find(m => m.type === 'reverb');
|
||||
if (oscs.length < 2 || lfos.length < 2 || !flt || !rev) return false;
|
||||
const graveBoth = oscs.filter(o => (o.params.frequency ?? 440) < 120).length >= 2;
|
||||
const hasDetune = oscs.some(o => Math.abs(o.params.detune ?? 0) > 2);
|
||||
const slowLfos = lfos.filter(l => (l.params.frequency ?? 2) < 1).length >= 2;
|
||||
const reverbLong = (rev.params.decay ?? 2) > 3;
|
||||
return graveBoth && hasDetune && slowLfos && reverbLong;
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// ─────────────── LEVEL 11.4 ───────────────
|
||||
{
|
||||
id: 'w11-4',
|
||||
title: 'Polifonía',
|
||||
subtitle: 'Múltiples voces simultáneamente',
|
||||
description: 'La polifonía significa tocar múltiples notas simultáneamente. En sintética, usas el keyboard con múltiples canales (oscs + envelopes) para que cada nota presionada active una "voz". Cada voz tiene su propio envelope y filtro.',
|
||||
concept: 'Cuatro "voces": cada una es Osc → Filter → VCA. Todas conectan a un Mixer → Output. Keyboard conectado a la freq de todos los oscs Y al gate de todos los envelopes. Así toca 4 notas a la vez.',
|
||||
availableModules: ['oscillator', 'filter', 'vca', 'envelope', 'mixer', 'keyboard'],
|
||||
preplacedModules: [
|
||||
{ id: 1, type: 'output', x: 900, y: 140, params: { volume: -8 }, locked: true },
|
||||
],
|
||||
target: {
|
||||
build: [
|
||||
{ type: 'oscillator', params: { waveform: 'sawtooth', frequency: 220, detune: 0 } },
|
||||
{ type: 'oscillator', params: { waveform: 'sawtooth', frequency: 220, detune: 3 } },
|
||||
{ type: 'oscillator', params: { waveform: 'square', frequency: 220, detune: -2 } },
|
||||
{ type: 'oscillator', params: { waveform: 'square', frequency: 220, detune: 5 } },
|
||||
],
|
||||
filter: { type: 'lowpass', frequency: 3500, Q: 1.2 },
|
||||
envelope: { attack: 0.07, decay: 0.4, sustain: 0.35, release: 0.25 },
|
||||
duration: 4,
|
||||
},
|
||||
checks: [
|
||||
{
|
||||
star: 1,
|
||||
name: 'Múltiples voces',
|
||||
desc: 'Al menos 3 oscs conectados al keyboard',
|
||||
test: (mods, conns) => {
|
||||
const oscs = mods.filter(m => m.type === 'oscillator');
|
||||
const kb = mods.find(m => m.type === 'keyboard');
|
||||
if (oscs.length < 3 || !kb) return false;
|
||||
const connectedToKb = oscs.filter(o =>
|
||||
conns.some(c => c.from.moduleId === kb.id && c.to.moduleId === o.id && c.to.port === 'freq')
|
||||
).length;
|
||||
return connectedToKb >= 3;
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 2,
|
||||
name: 'Voces completas',
|
||||
desc: '3+ oscs, cada uno pasa por filter + VCA, todos al mixer',
|
||||
test: (mods, conns) => {
|
||||
const oscs = mods.filter(m => m.type === 'oscillator');
|
||||
const flts = mods.filter(m => m.type === 'filter');
|
||||
const vcas = mods.filter(m => m.type === 'vca');
|
||||
const mixer = mods.find(m => m.type === 'mixer');
|
||||
if (oscs.length < 3 || flts.length < 3 || vcas.length < 3 || !mixer) return false;
|
||||
// Each osc should go through a filter and VCA
|
||||
let voiceCount = 0;
|
||||
oscs.forEach(o => {
|
||||
const hasFilter = conns.some(c => c.from.moduleId === o.id && c.to.moduleId === flts.find(f => true)?.id);
|
||||
if (hasFilter) voiceCount++;
|
||||
});
|
||||
return voiceCount >= 3;
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 3,
|
||||
name: 'Polisintetizador',
|
||||
desc: '4+ voces (osc+filter+vca), keyboard a freq Y gates, todos mezclados, envelopes',
|
||||
test: (mods, conns) => {
|
||||
const oscs = mods.filter(m => m.type === 'oscillator');
|
||||
const envs = mods.filter(m => m.type === 'envelope');
|
||||
const kb = mods.find(m => m.type === 'keyboard');
|
||||
const mixer = mods.find(m => m.type === 'mixer');
|
||||
if (oscs.length < 4 || envs.length < 3 || !kb || !mixer) return false;
|
||||
// Keyboard controls freq of oscs
|
||||
const kbFreq = oscs.filter(o =>
|
||||
conns.some(c => c.from.moduleId === kb.id && c.to.moduleId === o.id && c.to.port === 'freq')
|
||||
).length;
|
||||
// Keyboard controls gates
|
||||
const kbGates = envs.filter(e =>
|
||||
conns.some(c => c.from.moduleId === kb.id && c.to.moduleId === e.id && c.to.port === 'gate')
|
||||
).length;
|
||||
return kbFreq >= 3 && kbGates >= 3 && conns.length >= 12;
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// ─────────────── LEVEL 11.5 ───────────────
|
||||
{
|
||||
id: 'w11-5',
|
||||
title: 'Sidechain Simulation',
|
||||
subtitle: 'Bajar el volumen al ritmo',
|
||||
description: 'El sidechain es un efecto donde el volumen (amplitud) baja en ritmo con algo — típicamente un beat. Se simula aquí con un envelope o LFO de ritmo rápido que controla un VCA, creando "ducks" de volumen.',
|
||||
concept: 'Osc → Filter → VCA. Un segundo envelope (o sequencer) con ataque/decay rápidos controla la amplitud del VCA. Cada tiempo que el sidechain se "abre", suena; cuando "cierra", se silencia. Efecto de "bomba".',
|
||||
availableModules: ['oscillator', 'filter', 'vca', 'envelope', 'sequencer', 'mixer'],
|
||||
preplacedModules: [
|
||||
{ id: 1, type: 'output', x: 800, y: 120, params: { volume: -8 }, locked: true },
|
||||
],
|
||||
target: {
|
||||
build: [
|
||||
{ type: 'oscillator', params: { waveform: 'sawtooth', frequency: 150, detune: 0 } },
|
||||
],
|
||||
filter: { type: 'lowpass', frequency: 2800, Q: 1.3 },
|
||||
lfo: { frequency: 2.5, type: 'square', min: 0.1, max: 0.95, target: 'amplitude' },
|
||||
envelope: { attack: 0.03, decay: 0.25, sustain: 0.1, release: 0.15 },
|
||||
duration: 3,
|
||||
},
|
||||
checks: [
|
||||
{
|
||||
star: 1,
|
||||
name: 'VCA modulado',
|
||||
desc: 'Envelope o Sequencer al cv del VCA',
|
||||
test: (mods, conns) => {
|
||||
const vca = mods.find(m => m.type === 'vca');
|
||||
const env = mods.find(m => m.type === 'envelope');
|
||||
const seq = mods.find(m => m.type === 'sequencer');
|
||||
if (!vca || (!env && !seq)) return false;
|
||||
return (env && conns.some(c => c.from.moduleId === env.id && c.to.moduleId === vca.id && c.to.port === 'cv')) ||
|
||||
(seq && conns.some(c => c.from.moduleId === seq.id && c.to.moduleId === vca.id && c.to.port === 'cv'));
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 2,
|
||||
name: 'Ritmo percibible',
|
||||
desc: 'Envelope decay rápido (< 0.3s) para efecto "pump"',
|
||||
test: (mods) => {
|
||||
const envs = mods.filter(m => m.type === 'envelope');
|
||||
return envs.some(e => (e.params.decay ?? 0.2) < 0.3);
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 3,
|
||||
name: 'Sidechain completo',
|
||||
desc: 'Osc → Filter → VCA. Envelope rápido (< 0.3s) al cv, efecto pump clara',
|
||||
test: (mods, conns) => {
|
||||
const osc = mods.find(m => m.type === 'oscillator');
|
||||
const flt = mods.find(m => m.type === 'filter');
|
||||
const vca = mods.find(m => m.type === 'vca');
|
||||
const env = mods.find(m => m.type === 'envelope');
|
||||
if (!osc || !flt || !vca || !env) return false;
|
||||
const pump = (env.params.decay ?? 0.2) < 0.3 && (env.params.attack ?? 0.01) < 0.05;
|
||||
const toVca = conns.some(c => c.from.moduleId === env.id && c.to.moduleId === vca.id && c.to.port === 'cv');
|
||||
return pump && toVca && conns.length >= 4;
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// ─────────────── LEVEL 11.6 ───────────────
|
||||
{
|
||||
id: 'w11-6',
|
||||
title: 'Bucles de Retroalimentación',
|
||||
subtitle: 'Caos controlado con feedback',
|
||||
description: 'Al conectar la salida de un efecto (delay, reverb) de vuelta a su entrada, creas retroalimentación. Con los parámetros justos, genera texturas evolucionando lentamente. Con los parámetros equivocados, ¡explosión sónica!',
|
||||
concept: 'Osc → Filter → Delay. Salida del delay vuelve a su propia entrada (feedback alto 0.7-0.9). Reverb después del delay. Envelope muy largo para dejar que el feedback crezca. Los sonidos se multiplican y transforman constantemente.',
|
||||
availableModules: ['oscillator', 'filter', 'delay', 'reverb', 'vca', 'envelope'],
|
||||
preplacedModules: [
|
||||
{ id: 1, type: 'output', x: 800, y: 140, params: { volume: -10 }, locked: true },
|
||||
],
|
||||
target: {
|
||||
build: [
|
||||
{ type: 'oscillator', params: { waveform: 'sine', frequency: 120, detune: 0 } },
|
||||
],
|
||||
filter: { type: 'lowpass', frequency: 2000, Q: 1.5 },
|
||||
effects: [
|
||||
{ type: 'delay', delayTime: 0.5, feedback: 0.8, wet: 0.9 },
|
||||
{ type: 'reverb', decay: 3.5, wet: 0.6 },
|
||||
],
|
||||
envelope: { attack: 0.1, decay: 1, sustain: 0.4, release: 0.5 },
|
||||
duration: 4,
|
||||
},
|
||||
checks: [
|
||||
{
|
||||
star: 1,
|
||||
name: 'Delay en la cadena',
|
||||
desc: 'Osc → Filter → Delay → Output',
|
||||
test: (mods, conns) => {
|
||||
const osc = mods.find(m => m.type === 'oscillator');
|
||||
const flt = mods.find(m => m.type === 'filter');
|
||||
const del = mods.find(m => m.type === 'delay');
|
||||
const out = mods.find(m => m.type === 'output');
|
||||
if (!osc || !flt || !del || !out) return false;
|
||||
return conns.some(c => c.from.moduleId === flt.id && c.to.moduleId === del.id);
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 2,
|
||||
name: 'Feedback observable',
|
||||
desc: 'Delay con feedback > 0.5 para retroalimentación clara',
|
||||
test: (mods) => {
|
||||
const del = mods.find(m => m.type === 'delay');
|
||||
return del && (del.params.feedback ?? 0.4) > 0.5;
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 3,
|
||||
name: 'Texturas evolucionando',
|
||||
desc: 'Osc → Filtro → Delay (fb > 0.7) → Reverb, envelope largo, sonido crece y cambia',
|
||||
test: (mods, conns) => {
|
||||
const osc = mods.find(m => m.type === 'oscillator');
|
||||
const flt = mods.find(m => m.type === 'filter');
|
||||
const del = mods.find(m => m.type === 'delay');
|
||||
const rev = mods.find(m => m.type === 'reverb');
|
||||
const env = mods.find(m => m.type === 'envelope');
|
||||
if (!osc || !flt || !del || !rev || !env) return false;
|
||||
const highFb = (del.params.feedback ?? 0.4) > 0.7;
|
||||
const longEnv = (env.params.decay ?? 0.2) > 0.5;
|
||||
const chainOk = conns.some(c => c.from.moduleId === flt.id && c.to.moduleId === del.id) &&
|
||||
conns.some(c => c.from.moduleId === del.id && c.to.moduleId === rev.id);
|
||||
return highFb && longEnv && chainOk;
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// ─────────────── LEVEL 11.7 ───────────────
|
||||
{
|
||||
id: 'w11-7',
|
||||
title: 'Cross-Modulación',
|
||||
subtitle: 'LFOs modulándose entre sí',
|
||||
description: 'Cuando un LFO modula la frecuencia de otro LFO, creas patrones dinámicos impredecibles. Cuando un LFO modula la amplitud de otro, creas "breathing" de amplitud. Combines esto con osciladores para sonar experimental y alienígena.',
|
||||
concept: 'LFO1 lento (0.5 Hz) → modula freq del LFO2. LFO2 más rápido (4 Hz) → modula cutoff del filtro. Osc grave → Filter → Output. El patrón del filtro cambia constantemente porque LFO2 está siendo modulado.',
|
||||
availableModules: ['oscillator', 'filter', 'lfo', 'vca'],
|
||||
preplacedModules: [
|
||||
{ id: 1, type: 'output', x: 700, y: 120, params: { volume: -8 }, locked: true },
|
||||
],
|
||||
target: {
|
||||
build: [
|
||||
{ type: 'oscillator', params: { waveform: 'sine', frequency: 100, detune: 0 } },
|
||||
],
|
||||
filter: { type: 'lowpass', frequency: 2500, Q: 1.2 },
|
||||
lfo: [
|
||||
{ frequency: 0.4, type: 'sine', min: 1.5, max: 6.5, target: 'frequency' },
|
||||
{ frequency: 4.5, type: 'sine', min: 1000, max: 4500, target: 'frequency' },
|
||||
],
|
||||
envelope: { attack: 0.08, decay: 0.4, sustain: 0.3, release: 0.25 },
|
||||
duration: 3,
|
||||
},
|
||||
checks: [
|
||||
{
|
||||
star: 1,
|
||||
name: 'LFO al LFO',
|
||||
desc: 'Un LFO modulando la frecuencia de otro',
|
||||
test: (mods, conns) => {
|
||||
const lfos = mods.filter(m => m.type === 'lfo');
|
||||
if (lfos.length < 2) return false;
|
||||
return lfos.some(l => conns.some(c =>
|
||||
c.from.moduleId === l.id && c.to.moduleId === lfos.find(x => x.id !== l.id)?.id && c.to.port === 'frequency'
|
||||
));
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 2,
|
||||
name: 'Modulación en cascada',
|
||||
desc: 'LFO modulado a otro LFO, ese LFO modula filter cutoff',
|
||||
test: (mods, conns) => {
|
||||
const lfos = mods.filter(m => m.type === 'lfo');
|
||||
const flt = mods.find(m => m.type === 'filter');
|
||||
if (lfos.length < 2 || !flt) return false;
|
||||
const hasLfoToLfo = conns.some(c =>
|
||||
c.from.moduleId === lfos[0].id && c.to.moduleId === lfos[1].id
|
||||
);
|
||||
const hasLfoToFilter = conns.some(c =>
|
||||
c.from.moduleId === lfos[1].id && c.to.moduleId === flt.id && c.to.port === 'cutoff'
|
||||
);
|
||||
return hasLfoToLfo && hasLfoToFilter;
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 3,
|
||||
name: 'Cross-Mod experimental',
|
||||
desc: 'LFO lento (< 1 Hz) modula freq de LFO rápido (> 3 Hz), cutoff oscila dinámicamente',
|
||||
test: (mods, conns) => {
|
||||
const lfos = mods.filter(m => m.type === 'lfo');
|
||||
const flt = mods.find(m => m.type === 'filter');
|
||||
if (lfos.length < 2 || !flt) return false;
|
||||
const slowLfo = lfos.find(l => (l.params.frequency ?? 2) < 1);
|
||||
const fastLfo = lfos.find(l => (l.params.frequency ?? 2) > 3);
|
||||
if (!slowLfo || !fastLfo) return false;
|
||||
const crossMod = conns.some(c =>
|
||||
c.from.moduleId === slowLfo.id && c.to.moduleId === fastLfo.id && c.to.port === 'frequency'
|
||||
);
|
||||
const toFilter = conns.some(c =>
|
||||
c.from.moduleId === fastLfo.id && c.to.moduleId === flt.id && c.to.port === 'cutoff'
|
||||
);
|
||||
return crossMod && toFilter;
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// ─────────────── LEVEL 11.8: BOSS ───────────────
|
||||
{
|
||||
id: 'w11-8',
|
||||
title: 'Patch Experimental',
|
||||
subtitle: 'BOSS FINAL: Sin límites de creatividad',
|
||||
description: 'Has dominado las técnicas avanzadas. Ahora construye el patch más experimental, raro y creativo que puedas. Combina oscilaciones de filtro, modulación en anillo, feedback caótico, modulación cruzada... ¡Sin restricciones!',
|
||||
concept: 'Toma todo lo aprendido: self-oscillation, ring mod, drones, polifonía, sidechain, feedback, cross-mod. Combina al menos 3 técnicas avanzadas diferentes en un solo patch. 10+ módulos, 15+ conexiones. ¡Sorpréndete a ti mismo!',
|
||||
availableModules: ['oscillator', 'filter', 'vca', 'lfo', 'envelope', 'mixer', 'noise', 'keyboard', 'delay', 'reverb', 'distortion', 'sequencer'],
|
||||
preplacedModules: [
|
||||
{ id: 1, type: 'output', x: 950, y: 160, params: { volume: -10 }, locked: true },
|
||||
],
|
||||
target: {
|
||||
build: [
|
||||
{ type: 'noise', params: { type: 'white' } },
|
||||
{ type: 'oscillator', params: { waveform: 'sine', frequency: 80, detune: 0 } },
|
||||
{ type: 'oscillator', params: { waveform: 'sine', frequency: 80, detune: -3 } },
|
||||
{ type: 'oscillator', params: { waveform: 'sine', frequency: 7, detune: 0 } },
|
||||
],
|
||||
filter: { type: 'lowpass', frequency: 2200, Q: 8 },
|
||||
lfo: [
|
||||
{ frequency: 0.25, type: 'sine', min: 1000, max: 3500, target: 'frequency' },
|
||||
{ frequency: 2, type: 'sine', min: 0.2, max: 0.9, target: 'amplitude' },
|
||||
],
|
||||
effects: [
|
||||
{ type: 'distortion', distortion: 0.4, wet: 0.35 },
|
||||
{ type: 'delay', delayTime: 0.4, feedback: 0.65, wet: 0.7 },
|
||||
{ type: 'reverb', decay: 3.2, wet: 0.5 },
|
||||
],
|
||||
envelope: { attack: 0.12, decay: 0.6, sustain: 0.25, release: 0.4 },
|
||||
duration: 5,
|
||||
},
|
||||
checks: [
|
||||
{
|
||||
star: 1,
|
||||
name: 'Patch complejo',
|
||||
desc: 'Al menos 8 módulos, 10+ conexiones, sonido sólido',
|
||||
test: (mods, conns) => {
|
||||
const out = mods.find(m => m.type === 'output');
|
||||
if (!out) return false;
|
||||
const nonOutput = mods.filter(m => m.type !== 'output');
|
||||
const hasOutput = conns.some(c => c.to.moduleId === out.id);
|
||||
return nonOutput.length >= 8 && conns.length >= 10 && hasOutput;
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 2,
|
||||
name: 'Técnicas avanzadas',
|
||||
desc: 'Al menos 2 técnicas avanzadas reconocibles (self-osc, ring mod, feedback, cross-mod, etc)',
|
||||
test: (mods, conns) => {
|
||||
const flt = mods.find(m => m.type === 'filter');
|
||||
const del = mods.find(m => m.type === 'delay');
|
||||
const lfos = mods.filter(m => m.type === 'lfo');
|
||||
const vca = mods.find(m => m.type === 'vca');
|
||||
|
||||
let techCount = 0;
|
||||
// Self-oscillation check
|
||||
if (flt && (flt.params.Q ?? 1) > 7) techCount++;
|
||||
// Feedback loop
|
||||
if (del && (del.params.feedback ?? 0.4) > 0.6) techCount++;
|
||||
// Cross-mod (LFO to LFO)
|
||||
if (lfos.length >= 2 && conns.some(c =>
|
||||
lfos.some(l1 => lfos.some(l2 => l1.id !== l2.id && c.from.moduleId === l1.id && c.to.moduleId === l2.id))
|
||||
)) techCount++;
|
||||
// Ring mod (VCA as ring mod)
|
||||
if (vca && conns.some(c => c.to.moduleId === vca.id && c.to.port === 'cv') &&
|
||||
conns.some(c => c.to.moduleId === vca.id && c.to.port === 'in')) techCount++;
|
||||
|
||||
return techCount >= 2;
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 3,
|
||||
name: 'Maestro Avanzado',
|
||||
desc: '10+ módulos, 15+ conexiones, 3+ técnicas avanzadas, mixer, efectos, sonido único',
|
||||
test: (mods, conns) => {
|
||||
const nonOutput = mods.filter(m => m.type !== 'output');
|
||||
const mixer = mods.find(m => m.type === 'mixer');
|
||||
const effects = mods.filter(m => ['delay', 'reverb', 'distortion'].includes(m.type));
|
||||
const flt = mods.find(m => m.type === 'filter');
|
||||
const del = mods.find(m => m.type === 'delay');
|
||||
const lfos = mods.filter(m => m.type === 'lfo');
|
||||
|
||||
if (nonOutput.length < 10 || !mixer || effects.length === 0 || conns.length < 15) return false;
|
||||
|
||||
let techCount = 0;
|
||||
if (flt && (flt.params.Q ?? 1) > 7) techCount++;
|
||||
if (del && (del.params.feedback ?? 0.4) > 0.6) techCount++;
|
||||
if (lfos.length >= 2) techCount++;
|
||||
|
||||
return techCount >= 3;
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
605
packages/client/src/game/levels/world12.js
Normal file
605
packages/client/src/game/levels/world12.js
Normal file
@@ -0,0 +1,605 @@
|
||||
/**
|
||||
* World 12 — "Gran Final" (Grand Finale)
|
||||
*
|
||||
* Teaches: building a complete track from start to finish
|
||||
* 8 levels creating a full production: intro, drop, lead, breakdown, build-up, mix, outro
|
||||
* boss challenge: create a complete musical piece with scope visualization
|
||||
*/
|
||||
|
||||
export const WORLD_12 = {
|
||||
id: 'w12',
|
||||
name: 'Gran Final',
|
||||
subtitle: 'Tu obra maestra',
|
||||
icon: '♛',
|
||||
color: '#ffd700',
|
||||
unlockStars: 132,
|
||||
levels: [
|
||||
// ─────────────── LEVEL 12.1 ───────────────
|
||||
{
|
||||
id: 'w12-1',
|
||||
title: 'Intro Ambiental',
|
||||
subtitle: 'Comenzando suavemente',
|
||||
description: 'Toda gran pista comienza con una introducción ambiental. Crea una atmósfera con pads, sonidos largos y efectos de reverb/delay. Sin ritmo fuerte, solo texturas flotantes.',
|
||||
concept: 'Dos oscs sine graves detuned + Mixer → Filter LP → VCA con envelope muy largo → Reverb → Output. LFO lento al cutoff. Sin percusión, puro ambiente. Cero attack, máximo sustain.',
|
||||
availableModules: ['oscillator', 'filter', 'vca', 'lfo', 'envelope', 'mixer', 'reverb'],
|
||||
preplacedModules: [
|
||||
{ id: 1, type: 'output', x: 800, y: 120, params: { volume: -8 }, locked: true },
|
||||
],
|
||||
target: {
|
||||
build: [
|
||||
{ type: 'oscillator', params: { waveform: 'sine', frequency: 55, detune: 0 } },
|
||||
{ type: 'oscillator', params: { waveform: 'sine', frequency: 55, detune: -5 } },
|
||||
],
|
||||
filter: { type: 'lowpass', frequency: 1800, Q: 0.9 },
|
||||
lfo: { frequency: 0.2, type: 'sine', min: 800, max: 3200, target: 'frequency' },
|
||||
effects: [
|
||||
{ type: 'reverb', decay: 5.5, wet: 0.65 },
|
||||
],
|
||||
envelope: { attack: 0.01, decay: 2, sustain: 0.8, release: 0.6 },
|
||||
duration: 5,
|
||||
},
|
||||
checks: [
|
||||
{
|
||||
star: 1,
|
||||
name: 'Pad ambiental',
|
||||
desc: '2 oscs sine grave + reverb largo',
|
||||
test: (mods) => {
|
||||
const oscs = mods.filter(m => m.type === 'oscillator' && m.params.waveform === 'sine');
|
||||
const rev = mods.find(m => m.type === 'reverb');
|
||||
if (oscs.length < 2 || !rev) return false;
|
||||
return oscs.filter(o => (o.params.frequency ?? 440) < 150).length >= 2 &&
|
||||
(rev.params.decay ?? 2) > 3;
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 2,
|
||||
name: 'Evolución lenta',
|
||||
desc: 'LFO < 1 Hz modulando cutoff, envelope muy largo (decay > 1s)',
|
||||
test: (mods) => {
|
||||
const lfo = mods.find(m => m.type === 'lfo');
|
||||
const env = mods.find(m => m.type === 'envelope');
|
||||
if (!lfo || !env) return false;
|
||||
return (lfo.params.frequency ?? 2) < 1 &&
|
||||
(env.params.decay ?? 0.2) > 1 &&
|
||||
(env.params.sustain ?? 0.5) > 0.4;
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 3,
|
||||
name: 'Intro hipnótica',
|
||||
desc: '2+ oscs detuned, filter LP, LFO lento al cutoff, reverb > 4s, envelope attack 0',
|
||||
test: (mods, conns) => {
|
||||
const oscs = mods.filter(m => m.type === 'oscillator' && m.params.waveform === 'sine');
|
||||
const flt = mods.find(m => m.type === 'filter');
|
||||
const lfo = mods.find(m => m.type === 'lfo');
|
||||
const rev = mods.find(m => m.type === 'reverb');
|
||||
const env = mods.find(m => m.type === 'envelope');
|
||||
if (oscs.length < 2 || !flt || !lfo || !rev || !env) return false;
|
||||
const graveLong = oscs.filter(o => (o.params.frequency ?? 440) < 150).length >= 2;
|
||||
const hasDetune = oscs.some(o => Math.abs(o.params.detune ?? 0) > 2);
|
||||
const slowLfo = (lfo.params.frequency ?? 2) < 1;
|
||||
const longRev = (rev.params.decay ?? 2) > 4;
|
||||
const niceEnv = (env.params.attack ?? 0.01) < 0.05 && (env.params.decay ?? 0.2) > 1;
|
||||
const lfoToFilter = conns.some(c => c.from.moduleId === lfo.id && c.to.moduleId === flt.id && c.to.port === 'cutoff');
|
||||
return graveLong && hasDetune && slowLfo && longRev && niceEnv && lfoToFilter;
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// ─────────────── LEVEL 12.2 ───────────────
|
||||
{
|
||||
id: 'w12-2',
|
||||
title: 'El Drop',
|
||||
subtitle: 'Entra el beat con fuerza',
|
||||
description: 'Después de la intro, llega el drop: un cambio dramático donde entra el kick, snare y bass graves. Es el momento de tensión y energía. Combina un bass grave con un beat de síntesis.',
|
||||
concept: 'Dos elementos: 1) Drum: Osc sine grave (~55 Hz) con envelope rápido (attack 0, decay 0.2). 2) Bass: Oscs sawtooth detuned, filtro LP abierto, sonido gordo y agresivo. Sequencer para el ritmo.',
|
||||
availableModules: ['oscillator', 'vca', 'envelope', 'mixer', 'filter', 'sequencer'],
|
||||
preplacedModules: [
|
||||
{ id: 1, type: 'output', x: 800, y: 140, params: { volume: -6 }, locked: true },
|
||||
],
|
||||
target: {
|
||||
build: [
|
||||
{ type: 'oscillator', params: { waveform: 'sine', frequency: 55, detune: 0 } },
|
||||
{ type: 'oscillator', params: { waveform: 'sawtooth', frequency: 82, detune: 0 } },
|
||||
{ type: 'oscillator', params: { waveform: 'sawtooth', frequency: 82, detune: 4 } },
|
||||
],
|
||||
filter: { type: 'lowpass', frequency: 4500, Q: 1.1 },
|
||||
envelope: { attack: 0.01, decay: 0.18, sustain: 0.05, release: 0.1 },
|
||||
duration: 4,
|
||||
},
|
||||
checks: [
|
||||
{
|
||||
star: 1,
|
||||
name: 'Kick + Bass',
|
||||
desc: 'Osc grave con envelope corto (kick) + osc grave para bass',
|
||||
test: (mods, conns) => {
|
||||
const oscs = mods.filter(m => m.type === 'oscillator');
|
||||
const vca = mods.find(m => m.type === 'vca');
|
||||
const env = mods.find(m => m.type === 'envelope');
|
||||
if (oscs.length < 2 || !vca || !env) return false;
|
||||
const graveOscs = oscs.filter(o => (o.params.frequency ?? 440) < 100);
|
||||
return graveOscs.length >= 2 && (env.params.decay ?? 0.2) < 0.3;
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 2,
|
||||
name: 'Ritmo percibible',
|
||||
desc: 'Sequencer conectado, beat claro con kick percusivo',
|
||||
test: (mods, conns) => {
|
||||
const seq = mods.find(m => m.type === 'sequencer');
|
||||
const osc = mods.find(m => m.type === 'oscillator');
|
||||
const env = mods.find(m => m.type === 'envelope');
|
||||
if (!seq || !osc || !env) return false;
|
||||
return conns.some(c => c.from.moduleId === seq.id && c.to.moduleId === osc.id) &&
|
||||
(env.params.decay ?? 0.2) < 0.25;
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 3,
|
||||
name: 'Drop potente',
|
||||
desc: 'Kick < 80 Hz decay < 0.2s, bass sawtooth detuned, sequencer, sonido gordo y fuerte',
|
||||
test: (mods, conns) => {
|
||||
const oscs = mods.filter(m => m.type === 'oscillator');
|
||||
const seq = mods.find(m => m.type === 'sequencer');
|
||||
const env = mods.find(m => m.type === 'envelope');
|
||||
const flt = mods.find(m => m.type === 'filter');
|
||||
if (oscs.length < 2 || !seq || !env) return false;
|
||||
const kickOsc = oscs.find(o => (o.params.frequency ?? 440) < 80);
|
||||
const sawOscs = oscs.filter(o => o.params.waveform === 'sawtooth');
|
||||
const hasDetune = sawOscs.some(o => Math.abs(o.params.detune ?? 0) > 3);
|
||||
const fastKick = (env.params.decay ?? 0.2) < 0.2;
|
||||
const seqConnected = conns.some(c => c.from.moduleId === seq.id);
|
||||
return kickOsc && sawOscs.length > 0 && hasDetune && fastKick && seqConnected;
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// ─────────────── LEVEL 12.3 ───────────────
|
||||
{
|
||||
id: 'w12-3',
|
||||
title: 'Lead Melódico',
|
||||
subtitle: 'Melodía protagonista',
|
||||
description: 'Usa el piano roll para crear una melodía líder que brille sobre el bass. El lead es típicamente un solo sintetizado con oscilador brillante, filtro modulado y reverb para espaciosidad.',
|
||||
concept: 'Piano roll → Osc square/bright → Filter LP con resonancia → VCA → Reverb → Mixer. Envelope para notas definidas (attack corto, decay/sustain para "peso"). LFO lento al cutoff para movimiento.',
|
||||
availableModules: ['oscillator', 'filter', 'vca', 'envelope', 'lfo', 'pianoroll', 'reverb', 'mixer'],
|
||||
preplacedModules: [
|
||||
{ id: 1, type: 'output', x: 850, y: 140, params: { volume: -8 }, locked: true },
|
||||
],
|
||||
target: {
|
||||
build: [
|
||||
{ type: 'oscillator', params: { waveform: 'square', frequency: 440, detune: 0 } },
|
||||
],
|
||||
filter: { type: 'lowpass', frequency: 3800, Q: 3 },
|
||||
lfo: { frequency: 0.5, type: 'sine', min: 2000, max: 5500, target: 'frequency' },
|
||||
effects: [
|
||||
{ type: 'reverb', decay: 2.8, wet: 0.45 },
|
||||
],
|
||||
envelope: { attack: 0.04, decay: 0.5, sustain: 0.5, release: 0.3 },
|
||||
duration: 5,
|
||||
},
|
||||
checks: [
|
||||
{
|
||||
star: 1,
|
||||
name: 'Melodía activa',
|
||||
desc: 'Piano roll conectado a osc, notas reproducidas',
|
||||
test: (mods, conns) => {
|
||||
const pr = mods.find(m => m.type === 'pianoroll');
|
||||
const osc = mods.find(m => m.type === 'oscillator');
|
||||
if (!pr || !osc) return false;
|
||||
return conns.some(c => c.from.moduleId === pr.id && c.to.moduleId === osc.id && c.to.port === 'freq');
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 2,
|
||||
name: 'Lead con carácter',
|
||||
desc: 'Osc square/bright, filter resonante, envelope con ataque corto',
|
||||
test: (mods) => {
|
||||
const osc = mods.find(m => m.type === 'oscillator');
|
||||
const flt = mods.find(m => m.type === 'filter');
|
||||
const env = mods.find(m => m.type === 'envelope');
|
||||
if (!osc || !flt || !env) return false;
|
||||
const isBright = osc.params.waveform === 'square' || osc.params.waveform === 'sawtooth';
|
||||
const hasResonance = (flt.params.Q ?? 1) > 2;
|
||||
const quickAttack = (env.params.attack ?? 0.01) < 0.05;
|
||||
return isBright && hasResonance && quickAttack;
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 3,
|
||||
name: 'Lead melódico',
|
||||
desc: 'Piano roll + osc square con filter resonante + LFO al cutoff + reverb, notas claramente escuchables',
|
||||
test: (mods, conns) => {
|
||||
const pr = mods.find(m => m.type === 'pianoroll');
|
||||
const osc = mods.find(m => m.type === 'oscillator');
|
||||
const flt = mods.find(m => m.type === 'filter');
|
||||
const lfo = mods.find(m => m.type === 'lfo');
|
||||
const rev = mods.find(m => m.type === 'reverb');
|
||||
const env = mods.find(m => m.type === 'envelope');
|
||||
if (!pr || !osc || !flt || !lfo || !rev || !env) return false;
|
||||
const prConnected = conns.some(c => c.from.moduleId === pr.id && c.to.moduleId === osc.id && c.to.port === 'freq');
|
||||
const gateConnected = conns.some(c => c.from.moduleId === pr.id && c.to.moduleId === env.id && c.to.port === 'gate');
|
||||
const lfoToFilter = conns.some(c => c.from.moduleId === lfo.id && c.to.moduleId === flt.id && c.to.port === 'cutoff');
|
||||
return prConnected && gateConnected && lfoToFilter && (rev.params.decay ?? 2) > 2;
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// ─────────────── LEVEL 12.4 ───────────────
|
||||
{
|
||||
id: 'w12-4',
|
||||
title: 'Breakdown',
|
||||
subtitle: 'Menos es más',
|
||||
description: 'El breakdown es una sección donde quitas elementos clave para crear contraste. Quitas el kick, quitas el bass pesado, dejas solo los pads suaves o un synth secundario. Construye anticipación para el regreso.',
|
||||
concept: 'Calla el kick y bass de secciones previas. Deja solo pads suaves, lead melódico suave, y efectos. Opcional: introduce un elemento nuevo y suave (strings sintéticos, pad etéreo). Todo con reverb abundante.',
|
||||
availableModules: ['oscillator', 'filter', 'vca', 'envelope', 'lfo', 'mixer', 'reverb', 'pianoroll'],
|
||||
preplacedModules: [
|
||||
{ id: 1, type: 'output', x: 850, y: 140, params: { volume: -8 }, locked: true },
|
||||
],
|
||||
target: {
|
||||
build: [
|
||||
{ type: 'oscillator', params: { waveform: 'sine', frequency: 110, detune: 0 } },
|
||||
{ type: 'oscillator', params: { waveform: 'sine', frequency: 110, detune: -6 } },
|
||||
],
|
||||
filter: { type: 'lowpass', frequency: 2500, Q: 0.95 },
|
||||
lfo: { frequency: 0.15, type: 'sine', min: 1200, max: 3800, target: 'frequency' },
|
||||
effects: [
|
||||
{ type: 'reverb', decay: 4.5, wet: 0.7 },
|
||||
],
|
||||
envelope: { attack: 0.15, decay: 1.5, sustain: 0.6, release: 0.5 },
|
||||
duration: 5,
|
||||
},
|
||||
checks: [
|
||||
{
|
||||
star: 1,
|
||||
name: 'Sonido suave',
|
||||
desc: 'Oscs sine/pads, sin percusión aguda, reverb presente',
|
||||
test: (mods) => {
|
||||
const oscs = mods.filter(m => m.type === 'oscillator');
|
||||
const rev = mods.find(m => m.type === 'reverb');
|
||||
if (!rev || oscs.length < 1) return false;
|
||||
const sines = oscs.filter(o => o.params.waveform === 'sine');
|
||||
return sines.length >= 1 && (rev.params.decay ?? 2) > 2;
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 2,
|
||||
name: 'Atmósfera ambiental',
|
||||
desc: 'Múltiples layers suaves, LFO modulando filtro, no hay kicks agudos',
|
||||
test: (mods, conns) => {
|
||||
const oscs = mods.filter(m => m.type === 'oscillator');
|
||||
const flt = mods.find(m => m.type === 'filter');
|
||||
const lfo = mods.find(m => m.type === 'lfo');
|
||||
if (oscs.length < 2 || !flt || !lfo) return false;
|
||||
const softOscs = oscs.filter(o => (o.params.frequency ?? 440) < 200);
|
||||
return softOscs.length >= 1 &&
|
||||
conns.some(c => c.from.moduleId === lfo.id && c.to.moduleId === flt.id && c.to.port === 'cutoff');
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 3,
|
||||
name: 'Breakdown perfecto',
|
||||
desc: '2+ oscs suaves, filtro con LFO, envelope largo, reverb > 3s, sonido flotante y aéreo',
|
||||
test: (mods, conns) => {
|
||||
const oscs = mods.filter(m => m.type === 'oscillator');
|
||||
const flt = mods.find(m => m.type === 'filter');
|
||||
const lfo = mods.find(m => m.type === 'lfo');
|
||||
const rev = mods.find(m => m.type === 'reverb');
|
||||
const env = mods.find(m => m.type === 'envelope');
|
||||
if (oscs.length < 2 || !flt || !lfo || !rev || !env) return false;
|
||||
const softOscs = oscs.filter(o => (o.params.frequency ?? 440) < 200);
|
||||
const longEnv = (env.params.decay ?? 0.2) > 1 && (env.params.sustain ?? 0.5) > 0.3;
|
||||
const lfoToFilter = conns.some(c => c.from.moduleId === lfo.id && c.to.moduleId === flt.id && c.to.port === 'cutoff');
|
||||
return softOscs.length >= 2 && longEnv && lfoToFilter && (rev.params.decay ?? 2) > 3;
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// ─────────────── LEVEL 12.5 ───────────────
|
||||
{
|
||||
id: 'w12-5',
|
||||
title: 'Build-Up',
|
||||
subtitle: 'La tensión sube',
|
||||
description: 'El build-up es donde añades elementos gradualmente para construir tensión. Comienzas minimal, y lentamente añades más capas: pads, bass, efectos, filtros abriendo. La audiencia siente que algo grande viene.',
|
||||
concept: 'Empieza con un LFO lento abriendo un filtro sobre un oscilador suave. Gradualmente: añade un segundo osc, un tercer osc, baja el cutoff, suena más agresivo. El sequencer acelera. La reverb se vuelve más agresiva (menos decay).',
|
||||
availableModules: ['oscillator', 'filter', 'vca', 'lfo', 'envelope', 'mixer', 'reverb', 'sequencer'],
|
||||
preplacedModules: [
|
||||
{ id: 1, type: 'output', x: 850, y: 140, params: { volume: -6 }, locked: true },
|
||||
],
|
||||
target: {
|
||||
build: [
|
||||
{ type: 'oscillator', params: { waveform: 'sine', frequency: 130, detune: 0 } },
|
||||
{ type: 'oscillator', params: { waveform: 'sawtooth', frequency: 165, detune: 0 } },
|
||||
{ type: 'oscillator', params: { waveform: 'sawtooth', frequency: 165, detune: 3 } },
|
||||
],
|
||||
filter: { type: 'lowpass', frequency: 3000, Q: 1.4 },
|
||||
lfo: { frequency: 0.4, type: 'sine', min: 1500, max: 5000, target: 'frequency' },
|
||||
effects: [
|
||||
{ type: 'reverb', decay: 2, wet: 0.35 },
|
||||
],
|
||||
envelope: { attack: 0.08, decay: 0.6, sustain: 0.4, release: 0.3 },
|
||||
duration: 5,
|
||||
},
|
||||
checks: [
|
||||
{
|
||||
star: 1,
|
||||
name: 'Tensión creciente',
|
||||
desc: 'LFO modulando filter cutoff, sonido evoluciona',
|
||||
test: (mods, conns) => {
|
||||
const lfo = mods.find(m => m.type === 'lfo');
|
||||
const flt = mods.find(m => m.type === 'filter');
|
||||
if (!lfo || !flt) return false;
|
||||
return conns.some(c => c.from.moduleId === lfo.id && c.to.moduleId === flt.id && c.to.port === 'cutoff');
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 2,
|
||||
name: 'Múltiples layers',
|
||||
desc: '3+ oscs, filtro con LFO, sonido más agresivo que intro',
|
||||
test: (mods, conns) => {
|
||||
const oscs = mods.filter(m => m.type === 'oscillator');
|
||||
const flt = mods.find(m => m.type === 'filter');
|
||||
const seq = mods.find(m => m.type === 'sequencer');
|
||||
if (oscs.length < 3 || !flt) return false;
|
||||
const hasSeq = seq && conns.some(c => c.from.moduleId === seq.id);
|
||||
return hasSeq;
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 3,
|
||||
name: 'Build-Up intenso',
|
||||
desc: '3+ oscs, LFO lento al cutoff, sequencer activo, reverb < 2s (más seco), sonido cresce',
|
||||
test: (mods, conns) => {
|
||||
const oscs = mods.filter(m => m.type === 'oscillator');
|
||||
const flt = mods.find(m => m.type === 'filter');
|
||||
const lfo = mods.find(m => m.type === 'lfo');
|
||||
const seq = mods.find(m => m.type === 'sequencer');
|
||||
const rev = mods.find(m => m.type === 'reverb');
|
||||
if (oscs.length < 3 || !flt || !lfo || !seq) return false;
|
||||
const slowLfo = (lfo.params.frequency ?? 2) < 1;
|
||||
const dryReverb = rev && (rev.params.decay ?? 2) < 2.5;
|
||||
const seqConnected = conns.some(c => c.from.moduleId === seq.id);
|
||||
const lfoToFilter = conns.some(c => c.from.moduleId === lfo.id && c.to.moduleId === flt.id && c.to.port === 'cutoff');
|
||||
return slowLfo && seqConnected && lfoToFilter && conns.length >= 10;
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// ─────────────── LEVEL 12.6 ───────────────
|
||||
{
|
||||
id: 'w12-6',
|
||||
title: 'Mix Completo',
|
||||
subtitle: 'Todos los elementos unidos',
|
||||
description: 'Ahora mezcla todo: intro, drop, lead, breakdown, build-up. Todos los elementos están presentes. El desafío es balancear los volúmenes para que nada se ahogue. Usa un mixer y output con gain correcto.',
|
||||
concept: 'Enruta todos los elementos de secciones anteriores a un único mixer. Todos los canales del mixer contribuyen al sonido final. Ajusta los gains del mixer y output para balance: nada clipeado, nada muy suave. Sonido cohesivo.',
|
||||
availableModules: ['oscillator', 'filter', 'vca', 'lfo', 'envelope', 'mixer', 'reverb', 'sequencer', 'pianoroll'],
|
||||
preplacedModules: [
|
||||
{ id: 1, type: 'output', x: 950, y: 160, params: { volume: -10 }, locked: true },
|
||||
],
|
||||
target: {
|
||||
build: [
|
||||
{ type: 'oscillator', params: { waveform: 'sine', frequency: 55, detune: 0 } },
|
||||
{ type: 'oscillator', params: { waveform: 'sine', frequency: 55, detune: -5 } },
|
||||
{ type: 'oscillator', params: { waveform: 'sawtooth', frequency: 110, detune: 0 } },
|
||||
{ type: 'oscillator', params: { waveform: 'sawtooth', frequency: 110, detune: 4 } },
|
||||
{ type: 'oscillator', params: { waveform: 'square', frequency: 220, detune: 0 } },
|
||||
],
|
||||
filter: { type: 'lowpass', frequency: 3500, Q: 1.2 },
|
||||
lfo: { frequency: 0.35, type: 'sine', min: 1500, max: 4500, target: 'frequency' },
|
||||
effects: [
|
||||
{ type: 'reverb', decay: 2.5, wet: 0.5 },
|
||||
],
|
||||
envelope: { attack: 0.1, decay: 0.6, sustain: 0.35, release: 0.4 },
|
||||
duration: 6,
|
||||
},
|
||||
checks: [
|
||||
{
|
||||
star: 1,
|
||||
name: 'Mixer activo',
|
||||
desc: 'Mixer con múltiples entradas, output rellenado',
|
||||
test: (mods, conns) => {
|
||||
const mixer = mods.find(m => m.type === 'mixer');
|
||||
const out = mods.find(m => m.type === 'output');
|
||||
if (!mixer || !out) return false;
|
||||
const inputsToMixer = conns.filter(c => c.to.moduleId === mixer.id).length;
|
||||
const mixerToOut = conns.some(c => c.from.moduleId === mixer.id && c.to.moduleId === out.id);
|
||||
return inputsToMixer >= 2 && mixerToOut;
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 2,
|
||||
name: 'Balance de sonido',
|
||||
desc: 'Múltiples elementos (oscs, reverb, seq, pianoroll) todos en mixer',
|
||||
test: (mods, conns) => {
|
||||
const mixer = mods.find(m => m.type === 'mixer');
|
||||
const oscs = mods.filter(m => m.type === 'oscillator');
|
||||
const seq = mods.find(m => m.type === 'sequencer');
|
||||
const pr = mods.find(m => m.type === 'pianoroll');
|
||||
if (!mixer) return false;
|
||||
const inputCount = conns.filter(c => c.to.moduleId === mixer.id).length;
|
||||
return oscs.length >= 3 && inputCount >= 4;
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 3,
|
||||
name: 'Mix profesional',
|
||||
desc: '8+ elementos en mixer, sonido balanceado, output -10 a -6dB, 15+ conexiones totales',
|
||||
test: (mods, conns) => {
|
||||
const nonOut = mods.filter(m => m.type !== 'output');
|
||||
const mixer = mods.find(m => m.type === 'mixer');
|
||||
const out = mods.find(m => m.type === 'output');
|
||||
if (nonOut.length < 8 || !mixer || !out) return false;
|
||||
const inputsToMixer = conns.filter(c => c.to.moduleId === mixer.id).length;
|
||||
const outVolume = out.params.volume ?? -6;
|
||||
return inputsToMixer >= 5 && outVolume >= -12 && outVolume <= -4 && conns.length >= 15;
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// ─────────────── LEVEL 12.7 ───────────────
|
||||
{
|
||||
id: 'w12-7',
|
||||
title: 'Outro Etéreo',
|
||||
subtitle: 'Despedida musical',
|
||||
description: 'El outro es donde se desvanece todo. Quitas elementos poco a poco, quizás repites la intro ambiental, y añades mucha reverb para crear una sensación de distancia y cierre. El sonido debe desvanecer suavemente.',
|
||||
concept: 'Repite elementos de la intro: oscs sine graves detuned, filtro suave, LFO muy lento al cutoff, reverb LARGO (5+ segundos). Envelope con sustain muy bajo para fade suave. Opcional: distorsión suave o delay con feedback para movimiento final.',
|
||||
availableModules: ['oscillator', 'filter', 'vca', 'lfo', 'envelope', 'mixer', 'reverb', 'delay'],
|
||||
preplacedModules: [
|
||||
{ id: 1, type: 'output', x: 850, y: 140, params: { volume: -10 }, locked: true },
|
||||
],
|
||||
target: {
|
||||
build: [
|
||||
{ type: 'oscillator', params: { waveform: 'sine', frequency: 50, detune: 0 } },
|
||||
{ type: 'oscillator', params: { waveform: 'sine', frequency: 50, detune: -7 } },
|
||||
],
|
||||
filter: { type: 'lowpass', frequency: 1500, Q: 0.85 },
|
||||
lfo: { frequency: 0.12, type: 'sine', min: 600, max: 2500, target: 'frequency' },
|
||||
effects: [
|
||||
{ type: 'delay', delayTime: 0.6, feedback: 0.5, wet: 0.6 },
|
||||
{ type: 'reverb', decay: 6, wet: 0.75 },
|
||||
],
|
||||
envelope: { attack: 0.05, decay: 1.5, sustain: 0.2, release: 1 },
|
||||
duration: 5,
|
||||
},
|
||||
checks: [
|
||||
{
|
||||
star: 1,
|
||||
name: 'Reverb largo',
|
||||
desc: 'Reverb con decay > 4s para fade etéreo',
|
||||
test: (mods) => {
|
||||
const rev = mods.find(m => m.type === 'reverb');
|
||||
return rev && (rev.params.decay ?? 2) > 4;
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 2,
|
||||
name: 'Sonido desvanecido',
|
||||
desc: 'Oscs graves, LFO lento, reverb largo, envelope largo sin gates',
|
||||
test: (mods, conns) => {
|
||||
const oscs = mods.filter(m => m.type === 'oscillator');
|
||||
const lfo = mods.find(m => m.type === 'lfo');
|
||||
const rev = mods.find(m => m.type === 'reverb');
|
||||
const env = mods.find(m => m.type === 'envelope');
|
||||
if (oscs.length < 1 || !lfo || !rev || !env) return false;
|
||||
const softOscs = oscs.filter(o => (o.params.frequency ?? 440) < 150);
|
||||
const slowLfo = (lfo.params.frequency ?? 2) < 0.5;
|
||||
const veryLongRev = (rev.params.decay ?? 2) > 4;
|
||||
return softOscs.length >= 1 && slowLfo && veryLongRev;
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 3,
|
||||
name: 'Outro perfecto',
|
||||
subtitle: '2+ oscs graves detuned, LFO < 0.5 Hz, reverb > 5s, delay con feedback, sonido flota al silencio',
|
||||
test: (mods, conns) => {
|
||||
const oscs = mods.filter(m => m.type === 'oscillator');
|
||||
const flt = mods.find(m => m.type === 'filter');
|
||||
const lfo = mods.find(m => m.type === 'lfo');
|
||||
const rev = mods.find(m => m.type === 'reverb');
|
||||
const del = mods.find(m => m.type === 'delay');
|
||||
const env = mods.find(m => m.type === 'envelope');
|
||||
if (oscs.length < 2 || !flt || !lfo || !rev || !env) return false;
|
||||
const graveDetuned = oscs.filter(o => (o.params.frequency ?? 440) < 150).length >= 2 &&
|
||||
oscs.some(o => Math.abs(o.params.detune ?? 0) > 2);
|
||||
const verySlowLfo = (lfo.params.frequency ?? 2) < 0.5;
|
||||
const veryLongRev = (rev.params.decay ?? 2) > 5;
|
||||
const lfoToFilter = conns.some(c => c.from.moduleId === lfo.id && c.to.moduleId === flt.id && c.to.port === 'cutoff');
|
||||
return graveDetuned && verySlowLfo && veryLongRev && lfoToFilter;
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// ─────────────── LEVEL 12.8: BOSS FINAL ───────────────
|
||||
{
|
||||
id: 'w12-8',
|
||||
title: 'Tu Obra Maestra',
|
||||
subtitle: 'BOSS FINAL: Tu track completa',
|
||||
description: 'Eres un sintetista maestro. Construye una obra musical completa: una pista de principio a fin. Intro, drop, lead, breakdown, build-up, mezcla y outro. Usa el módulo scope para visualizar tu sonido. Sin límites. Solo tu visión.',
|
||||
concept: 'Crea un track de 10+ módulos y 12+ conexiones. Debe tener: keyboard O sequencer, pianoroll para lead, múltiples osciladores, filtros modulados, reverb/delay, y OBLIGATORIO: scope module para visualización. Mixer para balance. Sonido profesional, único y musical.',
|
||||
availableModules: ['oscillator', 'filter', 'vca', 'lfo', 'envelope', 'mixer', 'reverb', 'delay', 'sequencer', 'pianoroll', 'keyboard', 'scope'],
|
||||
preplacedModules: [
|
||||
{ id: 1, type: 'output', x: 1000, y: 160, params: { volume: -8 }, locked: true },
|
||||
],
|
||||
target: {
|
||||
build: [
|
||||
{ type: 'oscillator', params: { waveform: 'sine', frequency: 50, detune: 0 } },
|
||||
{ type: 'oscillator', params: { waveform: 'sine', frequency: 50, detune: -5 } },
|
||||
{ type: 'oscillator', params: { waveform: 'sawtooth', frequency: 110, detune: 0 } },
|
||||
{ type: 'oscillator', params: { waveform: 'sawtooth', frequency: 110, detune: 4 } },
|
||||
{ type: 'oscillator', params: { waveform: 'square', frequency: 220, detune: 0 } },
|
||||
{ type: 'oscillator', params: { waveform: 'square', frequency: 220, detune: -3 } },
|
||||
],
|
||||
filter: { type: 'lowpass', frequency: 3800, Q: 1.5 },
|
||||
lfo: [
|
||||
{ frequency: 0.3, type: 'sine', min: 1500, max: 4500, target: 'frequency' },
|
||||
{ frequency: 0.8, type: 'sine', min: 0.3, max: 0.9, target: 'amplitude' },
|
||||
],
|
||||
effects: [
|
||||
{ type: 'delay', delayTime: 0.35, feedback: 0.45, wet: 0.5 },
|
||||
{ type: 'reverb', decay: 3, wet: 0.55 },
|
||||
],
|
||||
envelope: { attack: 0.1, decay: 0.7, sustain: 0.4, release: 0.5 },
|
||||
duration: 8,
|
||||
},
|
||||
checks: [
|
||||
{
|
||||
star: 1,
|
||||
name: 'Track básica',
|
||||
desc: '10+ módulos, 12+ conexiones, scope presente, sonido a través de output',
|
||||
test: (mods, conns) => {
|
||||
const nonOut = mods.filter(m => m.type !== 'output');
|
||||
const scope = mods.find(m => m.type === 'scope');
|
||||
const out = mods.find(m => m.type === 'output');
|
||||
if (nonOut.length < 10 || !scope || !out) return false;
|
||||
const hasOutput = conns.some(c => c.to.moduleId === out.id);
|
||||
return conns.length >= 12 && hasOutput;
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 2,
|
||||
name: 'Estructura musical',
|
||||
desc: '4+ secciones reconocibles: lead, bass, pads, efectos. Scope visualiza.',
|
||||
test: (mods, conns) => {
|
||||
const oscs = mods.filter(m => m.type === 'oscillator');
|
||||
const seq = mods.find(m => m.type === 'sequencer');
|
||||
const pr = mods.find(m => m.type === 'pianoroll');
|
||||
const flt = mods.filter(m => m.type === 'filter');
|
||||
const rev = mods.find(m => m.type === 'reverb');
|
||||
const scope = mods.find(m => m.type === 'scope');
|
||||
if (oscs.length < 4 || !scope) return false;
|
||||
const hasSequencing = seq || pr;
|
||||
const hasMelody = (pr && conns.some(c => c.from.moduleId === pr.id)) ||
|
||||
(seq && conns.some(c => c.from.moduleId === seq.id));
|
||||
return hasSequencing && flt.length >= 2 && rev && hasMelody;
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 3,
|
||||
name: 'Masterpiece',
|
||||
desc: '10+ módulos, keyboard/sequencer/pianoroll, 4+ oscs, mixer, 3+ efectos, scope, 15+ conexiones, música profesional',
|
||||
test: (mods, conns) => {
|
||||
const nonOut = mods.filter(m => m.type !== 'output');
|
||||
const oscs = mods.filter(m => m.type === 'oscillator');
|
||||
const seq = mods.find(m => m.type === 'sequencer');
|
||||
const pr = mods.find(m => m.type === 'pianoroll');
|
||||
const kb = mods.find(m => m.type === 'keyboard');
|
||||
const mixer = mods.find(m => m.type === 'mixer');
|
||||
const effects = mods.filter(m => ['delay', 'reverb', 'distortion'].includes(m.type));
|
||||
const scope = mods.find(m => m.type === 'scope');
|
||||
if (nonOut.length < 10 || oscs.length < 4 || !mixer || !scope || conns.length < 15) return false;
|
||||
const hasControl = (seq && conns.some(c => c.from.moduleId === seq.id)) ||
|
||||
(pr && conns.some(c => c.from.moduleId === pr.id)) ||
|
||||
(kb && conns.some(c => c.from.moduleId === kb.id));
|
||||
return hasControl && effects.length >= 3 && conns.length >= 15;
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
490
packages/client/src/game/levels/world2.js
Normal file
490
packages/client/src/game/levels/world2.js
Normal file
@@ -0,0 +1,490 @@
|
||||
/**
|
||||
* World 2 — "Filtros" (Filters)
|
||||
*
|
||||
* Teaches: lowpass, highpass, bandpass, resonance, cutoff modulation
|
||||
* 8 levels, progressive difficulty
|
||||
*/
|
||||
|
||||
export const WORLD_2 = {
|
||||
id: 'w2',
|
||||
name: 'Filtros',
|
||||
subtitle: 'Esculpe el timbre con filtros',
|
||||
icon: '▽',
|
||||
color: '#ff6644',
|
||||
unlockStars: 12, // Need 12 stars from World 1 to unlock
|
||||
levels: [
|
||||
// ─────────────── LEVEL 2.1 ───────────────
|
||||
{
|
||||
id: 'w2-1',
|
||||
title: 'El Paso Bajo',
|
||||
subtitle: 'Quita los agudos',
|
||||
description: 'Un filtro paso bajo (lowpass) deja pasar las frecuencias graves y elimina las agudas. Es el filtro más usado en síntesis — piensa en cómo suena la música debajo del agua. Conecta el oscilador a través del filtro.',
|
||||
concept: 'Conecta: Oscillator → Filter → Output. El filtro ya está en modo lowpass. El knob "Cutoff" controla hasta qué frecuencia deja pasar. Bájalo para un sonido más oscuro.',
|
||||
availableModules: [],
|
||||
preplacedModules: [
|
||||
{ id: 1, type: 'oscillator', x: 80, y: 80, params: { waveform: 'sawtooth', frequency: 220, detune: 0 }, locked: true },
|
||||
{ id: 2, type: 'filter', x: 340, y: 80, params: { type: 'lowpass', frequency: 2000, Q: 1 }, locked: false },
|
||||
{ id: 3, type: 'output', x: 600, y: 100, params: { volume: -6 }, locked: true },
|
||||
],
|
||||
target: {
|
||||
build: [
|
||||
{ type: 'oscillator', params: { waveform: 'sawtooth', frequency: 220 } },
|
||||
],
|
||||
filter: { type: 'lowpass', frequency: 800 },
|
||||
duration: 2.5,
|
||||
},
|
||||
checks: [
|
||||
{
|
||||
star: 1,
|
||||
name: 'Señal filtrada',
|
||||
desc: 'Conecta oscilador → filtro → salida',
|
||||
test: (mods, conns) => {
|
||||
const osc = mods.find(m => m.type === 'oscillator');
|
||||
const flt = mods.find(m => m.type === 'filter');
|
||||
const out = mods.find(m => m.type === 'output');
|
||||
if (!osc || !flt || !out) return false;
|
||||
const oscToFlt = conns.some(c => c.from.moduleId === osc.id && c.to.moduleId === flt.id);
|
||||
const fltToOut = conns.some(c => c.from.moduleId === flt.id && c.to.moduleId === out.id);
|
||||
return oscToFlt && fltToOut;
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 2,
|
||||
name: 'Cutoff bajo',
|
||||
desc: 'Baja el cutoff por debajo de 1200 Hz',
|
||||
test: (mods) => {
|
||||
const flt = mods.find(m => m.type === 'filter');
|
||||
return flt && (flt.params.frequency ?? 2000) < 1200;
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 3,
|
||||
name: 'Sonido oscuro',
|
||||
desc: 'Cutoff cercano a 800 Hz (±200 Hz)',
|
||||
test: (mods) => {
|
||||
const flt = mods.find(m => m.type === 'filter');
|
||||
if (!flt) return false;
|
||||
return Math.abs((flt.params.frequency ?? 2000) - 800) <= 200;
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// ─────────────── LEVEL 2.2 ───────────────
|
||||
{
|
||||
id: 'w2-2',
|
||||
title: 'El Paso Alto',
|
||||
subtitle: 'Solo los agudos',
|
||||
description: 'El filtro paso alto (highpass) es lo opuesto: elimina los graves y deja pasar los agudos. Es perfecto para quitar el "barro" de un sonido o crear texturas etéreas y delgadas.',
|
||||
concept: 'Cambia el tipo de filtro a "highpass". Sube el cutoff para que solo pasen las frecuencias altas. Un cutoff de ~2000 Hz eliminará todo lo grave.',
|
||||
availableModules: ['filter'],
|
||||
preplacedModules: [
|
||||
{ id: 1, type: 'oscillator', x: 80, y: 80, params: { waveform: 'sawtooth', frequency: 220, detune: 0 }, locked: true },
|
||||
{ id: 2, type: 'output', x: 600, y: 100, params: { volume: -6 }, locked: true },
|
||||
],
|
||||
target: {
|
||||
build: [
|
||||
{ type: 'oscillator', params: { waveform: 'sawtooth', frequency: 220 } },
|
||||
],
|
||||
filter: { type: 'highpass', frequency: 2000 },
|
||||
duration: 2.5,
|
||||
},
|
||||
checks: [
|
||||
{
|
||||
star: 1,
|
||||
name: 'Filtro conectado',
|
||||
desc: 'Coloca un filtro entre oscilador y salida',
|
||||
test: (mods, conns) => {
|
||||
const osc = mods.find(m => m.type === 'oscillator');
|
||||
const flt = mods.find(m => m.type === 'filter');
|
||||
const out = mods.find(m => m.type === 'output');
|
||||
if (!osc || !flt || !out) return false;
|
||||
const oscToFlt = conns.some(c => c.from.moduleId === osc.id && c.to.moduleId === flt.id);
|
||||
const fltToOut = conns.some(c => c.from.moduleId === flt.id && c.to.moduleId === out.id);
|
||||
return oscToFlt && fltToOut;
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 2,
|
||||
name: 'Modo highpass',
|
||||
desc: 'Cambia el filtro a highpass',
|
||||
test: (mods) => {
|
||||
const flt = mods.find(m => m.type === 'filter');
|
||||
return flt && flt.params.type === 'highpass';
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 3,
|
||||
name: 'Cutoff preciso',
|
||||
desc: 'Cutoff cercano a 2000 Hz (±300 Hz)',
|
||||
test: (mods) => {
|
||||
const flt = mods.find(m => m.type === 'filter');
|
||||
if (!flt) return false;
|
||||
return flt.params.type === 'highpass' && Math.abs((flt.params.frequency ?? 1000) - 2000) <= 300;
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// ─────────────── LEVEL 2.3 ───────────────
|
||||
{
|
||||
id: 'w2-3',
|
||||
title: 'Resonancia',
|
||||
subtitle: 'El pico que canta',
|
||||
description: 'La resonancia (Q) amplifica las frecuencias justo alrededor del punto de corte. Con poca resonancia el filtro es suave. Con mucha, el filtro "canta" — es el sonido ácido clásico del TB-303.',
|
||||
concept: 'Sube el knob "Reso" (Q) del filtro a un valor alto (~8-12). Mantén el cutoff bajo (~600 Hz) con lowpass. Escucharás cómo el filtro enfatiza esa frecuencia.',
|
||||
availableModules: [],
|
||||
preplacedModules: [
|
||||
{ id: 1, type: 'oscillator', x: 80, y: 80, params: { waveform: 'sawtooth', frequency: 110, detune: 0 }, locked: true },
|
||||
{ id: 2, type: 'filter', x: 340, y: 80, params: { type: 'lowpass', frequency: 1000, Q: 1 }, locked: false },
|
||||
{ id: 3, type: 'output', x: 600, y: 100, params: { volume: -8 }, locked: true },
|
||||
],
|
||||
target: {
|
||||
build: [
|
||||
{ type: 'oscillator', params: { waveform: 'sawtooth', frequency: 110 } },
|
||||
],
|
||||
filter: { type: 'lowpass', frequency: 600, Q: 10 },
|
||||
duration: 2.5,
|
||||
},
|
||||
checks: [
|
||||
{
|
||||
star: 1,
|
||||
name: 'Señal filtrada',
|
||||
desc: 'Conecta oscilador → filtro → salida',
|
||||
test: (mods, conns) => {
|
||||
const osc = mods.find(m => m.type === 'oscillator');
|
||||
const flt = mods.find(m => m.type === 'filter');
|
||||
const out = mods.find(m => m.type === 'output');
|
||||
if (!osc || !flt || !out) return false;
|
||||
return conns.some(c => c.from.moduleId === osc.id && c.to.moduleId === flt.id) &&
|
||||
conns.some(c => c.from.moduleId === flt.id && c.to.moduleId === out.id);
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 2,
|
||||
name: 'Resonancia alta',
|
||||
desc: 'Sube la resonancia (Q) por encima de 5',
|
||||
test: (mods) => {
|
||||
const flt = mods.find(m => m.type === 'filter');
|
||||
return flt && (flt.params.Q ?? 1) > 5;
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 3,
|
||||
name: 'Sonido ácido',
|
||||
desc: 'Q alto (~8-12) y cutoff bajo (~600 Hz ±200)',
|
||||
test: (mods) => {
|
||||
const flt = mods.find(m => m.type === 'filter');
|
||||
if (!flt) return false;
|
||||
const q = flt.params.Q ?? 1;
|
||||
const freq = flt.params.frequency ?? 1000;
|
||||
return q >= 7 && q <= 15 && Math.abs(freq - 600) <= 200;
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// ─────────────── LEVEL 2.4 ───────────────
|
||||
{
|
||||
id: 'w2-4',
|
||||
title: 'Banda Pasante',
|
||||
subtitle: 'Solo el medio',
|
||||
description: 'El filtro bandpass deja pasar solo un rango estrecho de frecuencias alrededor del cutoff. Es como poner un lowpass y un highpass a la vez. Crea sonidos nasales, tipo telefono o walkie-talkie.',
|
||||
concept: 'Cambia el tipo a "bandpass". El cutoff define el centro de la banda. Sube la Q para hacerla más estrecha (más nasal). Un cutoff de ~1000 Hz con Q alta suena como una voz por teléfono.',
|
||||
availableModules: ['filter'],
|
||||
preplacedModules: [
|
||||
{ id: 1, type: 'noise', x: 80, y: 80, params: { type: 'white' }, locked: true },
|
||||
{ id: 2, type: 'output', x: 600, y: 100, params: { volume: -10 }, locked: true },
|
||||
],
|
||||
target: {
|
||||
build: [
|
||||
{ type: 'noise', params: { type: 'white' } },
|
||||
],
|
||||
filter: { type: 'bandpass', frequency: 1000, Q: 8 },
|
||||
duration: 2,
|
||||
},
|
||||
checks: [
|
||||
{
|
||||
star: 1,
|
||||
name: 'Ruido filtrado',
|
||||
desc: 'Coloca filtro entre noise y salida',
|
||||
test: (mods, conns) => {
|
||||
const noise = mods.find(m => m.type === 'noise');
|
||||
const flt = mods.find(m => m.type === 'filter');
|
||||
const out = mods.find(m => m.type === 'output');
|
||||
if (!noise || !flt || !out) return false;
|
||||
return conns.some(c => c.from.moduleId === noise.id && c.to.moduleId === flt.id) &&
|
||||
conns.some(c => c.from.moduleId === flt.id && c.to.moduleId === out.id);
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 2,
|
||||
name: 'Modo bandpass',
|
||||
desc: 'Filtro en modo bandpass',
|
||||
test: (mods) => {
|
||||
const flt = mods.find(m => m.type === 'filter');
|
||||
return flt && flt.params.type === 'bandpass';
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 3,
|
||||
name: 'Nasal perfecto',
|
||||
desc: 'Bandpass a ~1000 Hz con Q alta (>5)',
|
||||
test: (mods) => {
|
||||
const flt = mods.find(m => m.type === 'filter');
|
||||
if (!flt) return false;
|
||||
return flt.params.type === 'bandpass' &&
|
||||
Math.abs((flt.params.frequency ?? 1000) - 1000) <= 300 &&
|
||||
(flt.params.Q ?? 1) > 5;
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// ─────────────── LEVEL 2.5 ───────────────
|
||||
{
|
||||
id: 'w2-5',
|
||||
title: 'Filtro en Movimiento',
|
||||
subtitle: 'LFO → Cutoff',
|
||||
description: 'Los filtros estáticos son útiles, pero los filtros en movimiento son mágicos. Conectar un LFO al cutoff de un filtro crea un barrido cíclico — es el sonido "wah-wah" clásico del funk y la música electrónica.',
|
||||
concept: 'Conecta un LFO a la entrada "Cutoff" del filtro. El LFO modulará el punto de corte automáticamente. Ajusta la velocidad del LFO (~2-4 Hz) para un wobble rítmico.',
|
||||
availableModules: ['lfo'],
|
||||
preplacedModules: [
|
||||
{ id: 1, type: 'oscillator', x: 80, y: 60, params: { waveform: 'sawtooth', frequency: 110, detune: 0 }, locked: true },
|
||||
{ id: 2, type: 'filter', x: 340, y: 60, params: { type: 'lowpass', frequency: 800, Q: 5 }, locked: false },
|
||||
{ id: 3, type: 'output', x: 600, y: 80, params: { volume: -8 }, locked: true },
|
||||
],
|
||||
target: {
|
||||
build: [
|
||||
{ type: 'oscillator', params: { waveform: 'sawtooth', frequency: 110 } },
|
||||
],
|
||||
filter: { type: 'lowpass', frequency: 800, Q: 5 },
|
||||
duration: 3,
|
||||
},
|
||||
checks: [
|
||||
{
|
||||
star: 1,
|
||||
name: 'Cadena de audio',
|
||||
desc: 'Oscilador → filtro → salida conectados',
|
||||
test: (mods, conns) => {
|
||||
const osc = mods.find(m => m.type === 'oscillator');
|
||||
const flt = mods.find(m => m.type === 'filter');
|
||||
const out = mods.find(m => m.type === 'output');
|
||||
if (!osc || !flt || !out) return false;
|
||||
return conns.some(c => c.from.moduleId === osc.id && c.to.moduleId === flt.id) &&
|
||||
conns.some(c => c.from.moduleId === flt.id && c.to.moduleId === out.id);
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 2,
|
||||
name: 'LFO conectado',
|
||||
desc: 'Conecta un LFO a la entrada Cutoff del filtro',
|
||||
test: (mods, conns) => {
|
||||
const lfo = mods.find(m => m.type === 'lfo');
|
||||
const flt = mods.find(m => m.type === 'filter');
|
||||
if (!lfo || !flt) return false;
|
||||
return conns.some(c => c.from.moduleId === lfo.id && c.to.moduleId === flt.id && c.to.port === 'cutoff');
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 3,
|
||||
name: 'Wobble rítmico',
|
||||
desc: 'LFO entre 1-6 Hz, resonancia > 3',
|
||||
test: (mods) => {
|
||||
const lfo = mods.find(m => m.type === 'lfo');
|
||||
const flt = mods.find(m => m.type === 'filter');
|
||||
if (!lfo || !flt) return false;
|
||||
const rate = lfo.params.frequency ?? 2;
|
||||
return rate >= 1 && rate <= 6 && (flt.params.Q ?? 1) > 3;
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// ─────────────── LEVEL 2.6 ───────────────
|
||||
{
|
||||
id: 'w2-6',
|
||||
title: 'Dos Filtros',
|
||||
subtitle: 'Escultura sónica',
|
||||
description: 'Los ingenieros de sonido encadenan filtros para obtener formas más complejas. Un highpass para quitar el subgrave seguido de un lowpass para suavizar los agudos es una técnica estándar de mezcla.',
|
||||
concept: 'Conecta: Oscilador → Filtro 1 (highpass, ~200 Hz) → Filtro 2 (lowpass, ~3000 Hz) → Output. Esto deja solo las frecuencias medias — "limpia" el sonido.',
|
||||
availableModules: ['filter'],
|
||||
preplacedModules: [
|
||||
{ id: 1, type: 'oscillator', x: 60, y: 80, params: { waveform: 'sawtooth', frequency: 110, detune: 0 }, locked: true },
|
||||
{ id: 2, type: 'output', x: 720, y: 100, params: { volume: -6 }, locked: true },
|
||||
],
|
||||
target: {
|
||||
build: [
|
||||
{ type: 'oscillator', params: { waveform: 'sawtooth', frequency: 110 } },
|
||||
],
|
||||
duration: 2.5,
|
||||
},
|
||||
checks: [
|
||||
{
|
||||
star: 1,
|
||||
name: 'Cadena doble',
|
||||
desc: 'Oscilador → filtro → filtro → salida',
|
||||
test: (mods, conns) => {
|
||||
const osc = mods.find(m => m.type === 'oscillator');
|
||||
const flts = mods.filter(m => m.type === 'filter');
|
||||
const out = mods.find(m => m.type === 'output');
|
||||
if (!osc || flts.length < 2 || !out) return false;
|
||||
// Check chain exists
|
||||
const oscToFlt = flts.some(f => conns.some(c => c.from.moduleId === osc.id && c.to.moduleId === f.id));
|
||||
const fltToOut = flts.some(f => conns.some(c => c.from.moduleId === f.id && c.to.moduleId === out.id));
|
||||
const fltToFlt = flts.some(f1 => flts.some(f2 =>
|
||||
f1.id !== f2.id && conns.some(c => c.from.moduleId === f1.id && c.to.moduleId === f2.id)
|
||||
));
|
||||
return oscToFlt && fltToOut && fltToFlt;
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 2,
|
||||
name: 'Highpass + Lowpass',
|
||||
desc: 'Un filtro en highpass y otro en lowpass',
|
||||
test: (mods) => {
|
||||
const flts = mods.filter(m => m.type === 'filter');
|
||||
if (flts.length < 2) return false;
|
||||
const types = flts.map(f => f.params.type);
|
||||
return types.includes('highpass') && types.includes('lowpass');
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 3,
|
||||
name: 'Banda limpia',
|
||||
desc: 'HP ~200 Hz (±100) + LP ~3000 Hz (±1000)',
|
||||
test: (mods) => {
|
||||
const flts = mods.filter(m => m.type === 'filter');
|
||||
const hp = flts.find(f => f.params.type === 'highpass');
|
||||
const lp = flts.find(f => f.params.type === 'lowpass');
|
||||
if (!hp || !lp) return false;
|
||||
return Math.abs((hp.params.frequency ?? 1000) - 200) <= 100 &&
|
||||
Math.abs((lp.params.frequency ?? 1000) - 3000) <= 1000;
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// ─────────────── LEVEL 2.7 ───────────────
|
||||
{
|
||||
id: 'w2-7',
|
||||
title: 'Filtro + Mezcla',
|
||||
subtitle: 'Timbres paralelos',
|
||||
description: 'Filtra dos osciladores de forma diferente y mézclalos. Es la base del diseño de sonido: capas con diferentes caracteres tímbricos que juntas crean algo más rico que la suma de sus partes.',
|
||||
concept: 'Dos osciladores, cada uno con su propio filtro (diferentes cutoffs), ambos al mixer, mixer al output. Uno oscuro y gordo (LP bajo), otro brillante (LP alto o sin filtro).',
|
||||
availableModules: ['oscillator', 'filter', 'mixer'],
|
||||
preplacedModules: [
|
||||
{ id: 1, type: 'output', x: 800, y: 140, params: { volume: -8 }, locked: true },
|
||||
],
|
||||
target: {
|
||||
build: [
|
||||
{ type: 'oscillator', params: { waveform: 'sawtooth', frequency: 110 } },
|
||||
{ type: 'oscillator', params: { waveform: 'square', frequency: 220 } },
|
||||
],
|
||||
duration: 2.5,
|
||||
},
|
||||
checks: [
|
||||
{
|
||||
star: 1,
|
||||
name: 'Dos cadenas',
|
||||
desc: 'Dos osciladores, cada uno filtrado, al mixer',
|
||||
test: (mods, conns) => {
|
||||
const oscs = mods.filter(m => m.type === 'oscillator');
|
||||
const flts = mods.filter(m => m.type === 'filter');
|
||||
const mixer = mods.find(m => m.type === 'mixer');
|
||||
const out = mods.find(m => m.type === 'output');
|
||||
return oscs.length >= 2 && flts.length >= 2 && mixer && out &&
|
||||
conns.some(c => c.from.moduleId === mixer.id && c.to.moduleId === out.id);
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 2,
|
||||
name: 'Filtros diferentes',
|
||||
desc: 'Los dos filtros tienen cutoffs distintos (diferencia > 500 Hz)',
|
||||
test: (mods) => {
|
||||
const flts = mods.filter(m => m.type === 'filter');
|
||||
if (flts.length < 2) return false;
|
||||
const freqs = flts.map(f => f.params.frequency ?? 1000);
|
||||
return Math.abs(freqs[0] - freqs[1]) > 500;
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 3,
|
||||
name: 'Capas contrastadas',
|
||||
desc: 'Un filtro oscuro (<600 Hz) y otro brillante (>3000 Hz)',
|
||||
test: (mods) => {
|
||||
const flts = mods.filter(m => m.type === 'filter');
|
||||
if (flts.length < 2) return false;
|
||||
const freqs = flts.map(f => f.params.frequency ?? 1000).sort((a, b) => a - b);
|
||||
return freqs[0] < 600 && freqs[freqs.length - 1] > 3000;
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// ─────────────── LEVEL 2.8: BOSS ───────────────
|
||||
{
|
||||
id: 'w2-8',
|
||||
title: 'Acid Bass',
|
||||
subtitle: 'BOSS: El sonido TB-303',
|
||||
description: 'El Roland TB-303 definió el acid house. Su secreto: un oscilador cuadrado/sierra a frecuencia baja, un filtro lowpass con MUCHA resonancia, y modulación del cutoff. Recrea ese sonido legendario.',
|
||||
concept: 'Oscilador saw a ~55-110 Hz → Filtro lowpass con Q alta (~12-15) y cutoff medio (~400-800 Hz) → Output. Añade un LFO lento (~0.5-2 Hz) modulando el cutoff para el movimiento ácido.',
|
||||
availableModules: ['oscillator', 'filter', 'lfo'],
|
||||
preplacedModules: [
|
||||
{ id: 1, type: 'output', x: 700, y: 120, params: { volume: -8 }, locked: true },
|
||||
],
|
||||
target: {
|
||||
build: [
|
||||
{ type: 'oscillator', params: { waveform: 'sawtooth', frequency: 82 } },
|
||||
],
|
||||
filter: { type: 'lowpass', frequency: 500, Q: 14 },
|
||||
duration: 3,
|
||||
},
|
||||
checks: [
|
||||
{
|
||||
star: 1,
|
||||
name: 'Cadena ácida',
|
||||
desc: 'Oscilador → filtro → salida con LFO al cutoff',
|
||||
test: (mods, conns) => {
|
||||
const osc = mods.find(m => m.type === 'oscillator');
|
||||
const flt = mods.find(m => m.type === 'filter');
|
||||
const lfo = mods.find(m => m.type === 'lfo');
|
||||
const out = mods.find(m => m.type === 'output');
|
||||
if (!osc || !flt || !out || !lfo) return false;
|
||||
return conns.some(c => c.from.moduleId === osc.id && c.to.moduleId === flt.id) &&
|
||||
conns.some(c => c.from.moduleId === flt.id && c.to.moduleId === out.id) &&
|
||||
conns.some(c => c.from.moduleId === lfo.id && c.to.moduleId === flt.id && c.to.port === 'cutoff');
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 2,
|
||||
name: 'Resonancia ácida',
|
||||
desc: 'Filtro lowpass con Q > 10',
|
||||
test: (mods) => {
|
||||
const flt = mods.find(m => m.type === 'filter');
|
||||
return flt && flt.params.type === 'lowpass' && (flt.params.Q ?? 1) > 10;
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 3,
|
||||
name: '303 auténtico',
|
||||
desc: 'Saw/square baja (<130Hz), Q>10, cutoff 300-900Hz, LFO lento (<3Hz)',
|
||||
test: (mods) => {
|
||||
const osc = mods.find(m => m.type === 'oscillator');
|
||||
const flt = mods.find(m => m.type === 'filter');
|
||||
const lfo = mods.find(m => m.type === 'lfo');
|
||||
if (!osc || !flt || !lfo) return false;
|
||||
const freq = osc.params.frequency ?? 440;
|
||||
const wave = osc.params.waveform;
|
||||
const cutoff = flt.params.frequency ?? 1000;
|
||||
const q = flt.params.Q ?? 1;
|
||||
const rate = lfo.params.frequency ?? 2;
|
||||
return freq < 130 && (wave === 'sawtooth' || wave === 'square') &&
|
||||
flt.params.type === 'lowpass' && q > 10 &&
|
||||
cutoff >= 300 && cutoff <= 900 && rate < 3;
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
511
packages/client/src/game/levels/world3.js
Normal file
511
packages/client/src/game/levels/world3.js
Normal file
@@ -0,0 +1,511 @@
|
||||
/**
|
||||
* World 3 — "Envelopes" (ADSR)
|
||||
*
|
||||
* Teaches: attack, decay, sustain, release, VCA, amplitude shaping, sound design
|
||||
* 8 levels, progressive difficulty
|
||||
*/
|
||||
|
||||
export const WORLD_3 = {
|
||||
id: 'w3',
|
||||
name: 'Envelopes',
|
||||
subtitle: 'Dale forma al sonido en el tiempo',
|
||||
icon: '⏤╲',
|
||||
color: '#aa55ff',
|
||||
unlockStars: 24, // Need 24 stars from World 1+2 to unlock
|
||||
levels: [
|
||||
// ─────────────── LEVEL 3.1 ───────────────
|
||||
{
|
||||
id: 'w3-1',
|
||||
title: 'El VCA',
|
||||
subtitle: 'Control de volumen',
|
||||
description: 'Un VCA (Voltage Controlled Amplifier) es un amplificador cuyo volumen se puede controlar con una señal externa. Pasa el oscilador por un VCA para poder controlar su volumen.',
|
||||
concept: 'Conecta: Oscilador → VCA (input "In") → Output. El knob "Gain" del VCA controla cuánto deja pasar. Es como un grifo para el sonido.',
|
||||
availableModules: [],
|
||||
preplacedModules: [
|
||||
{ id: 1, type: 'oscillator', x: 80, y: 80, params: { waveform: 'sawtooth', frequency: 220, detune: 0 }, locked: true },
|
||||
{ id: 2, type: 'vca', x: 340, y: 80, params: { gain: 0.5 }, locked: false },
|
||||
{ id: 3, type: 'output', x: 580, y: 100, params: { volume: -6 }, locked: true },
|
||||
],
|
||||
target: {
|
||||
build: [
|
||||
{ type: 'oscillator', params: { waveform: 'sawtooth', frequency: 220 } },
|
||||
],
|
||||
duration: 2,
|
||||
},
|
||||
checks: [
|
||||
{
|
||||
star: 1,
|
||||
name: 'VCA conectado',
|
||||
desc: 'Conecta oscilador → VCA → salida',
|
||||
test: (mods, conns) => {
|
||||
const osc = mods.find(m => m.type === 'oscillator');
|
||||
const vca = mods.find(m => m.type === 'vca');
|
||||
const out = mods.find(m => m.type === 'output');
|
||||
if (!osc || !vca || !out) return false;
|
||||
return conns.some(c => c.from.moduleId === osc.id && c.to.moduleId === vca.id && c.to.port === 'in') &&
|
||||
conns.some(c => c.from.moduleId === vca.id && c.to.moduleId === out.id);
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 2,
|
||||
name: 'Volumen moderado',
|
||||
desc: 'Gain del VCA por debajo de 0.7',
|
||||
test: (mods) => {
|
||||
const vca = mods.find(m => m.type === 'vca');
|
||||
return vca && (vca.params.gain ?? 0.8) < 0.7;
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 3,
|
||||
name: 'Medio volumen',
|
||||
desc: 'Gain cercano a 0.5 (±0.1)',
|
||||
test: (mods) => {
|
||||
const vca = mods.find(m => m.type === 'vca');
|
||||
return vca && Math.abs((vca.params.gain ?? 0.8) - 0.5) <= 0.1;
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// ─────────────── LEVEL 3.2 ───────────────
|
||||
{
|
||||
id: 'w3-2',
|
||||
title: 'ADSR',
|
||||
subtitle: 'Las 4 fases del sonido',
|
||||
description: 'Todo sonido tiene una forma en el tiempo: el Attack (subida), Decay (bajada), Sustain (mantenimiento) y Release (apagado). Un Envelope genera esa curva ADSR que puedes usar para controlar el VCA.',
|
||||
concept: 'Conecta el Envelope al VCA: la salida del Envelope → entrada CV del VCA. Conecta el Keyboard al Gate del Envelope para que se dispare al tocar. Toca notas y escucha cómo el Envelope da forma al volumen.',
|
||||
availableModules: ['envelope', 'keyboard'],
|
||||
preplacedModules: [
|
||||
{ id: 1, type: 'oscillator', x: 80, y: 60, params: { waveform: 'square', frequency: 440, detune: 0 }, locked: true },
|
||||
{ id: 2, type: 'vca', x: 400, y: 60, params: { gain: 0 }, locked: false },
|
||||
{ id: 3, type: 'output', x: 620, y: 80, params: { volume: -6 }, locked: true },
|
||||
],
|
||||
target: {
|
||||
build: [
|
||||
{ type: 'oscillator', params: { waveform: 'square', frequency: 440 } },
|
||||
],
|
||||
envelope: { attack: 0.2, decay: 0.15, sustain: 0.6, release: 0.5 },
|
||||
duration: 2,
|
||||
},
|
||||
checks: [
|
||||
{
|
||||
star: 1,
|
||||
name: 'Cadena con VCA',
|
||||
desc: 'Oscilador → VCA → Salida',
|
||||
test: (mods, conns) => {
|
||||
const osc = mods.find(m => m.type === 'oscillator');
|
||||
const vca = mods.find(m => m.type === 'vca');
|
||||
const out = mods.find(m => m.type === 'output');
|
||||
if (!osc || !vca || !out) return false;
|
||||
return conns.some(c => c.from.moduleId === osc.id && c.to.moduleId === vca.id) &&
|
||||
conns.some(c => c.from.moduleId === vca.id && c.to.moduleId === out.id);
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 2,
|
||||
name: 'Envelope al VCA',
|
||||
desc: 'Conecta Envelope → VCA (CV) y Keyboard → Envelope (Gate)',
|
||||
test: (mods, conns) => {
|
||||
const env = mods.find(m => m.type === 'envelope');
|
||||
const vca = mods.find(m => m.type === 'vca');
|
||||
const kb = mods.find(m => m.type === 'keyboard');
|
||||
if (!env || !vca || !kb) return false;
|
||||
return conns.some(c => c.from.moduleId === env.id && c.to.moduleId === vca.id && c.to.port === 'cv') &&
|
||||
conns.some(c => c.from.moduleId === kb.id && c.to.moduleId === env.id && c.to.port === 'gate');
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 3,
|
||||
name: 'Keyboard controla frecuencia',
|
||||
desc: 'Keyboard → Osc (Freq) para tocar melodías',
|
||||
test: (mods, conns) => {
|
||||
const kb = mods.find(m => m.type === 'keyboard');
|
||||
const osc = mods.find(m => m.type === 'oscillator');
|
||||
if (!kb || !osc) return false;
|
||||
return conns.some(c => c.from.moduleId === kb.id && c.from.port === 'freq' && c.to.moduleId === osc.id && c.to.port === 'freq');
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// ─────────────── LEVEL 3.3 ───────────────
|
||||
{
|
||||
id: 'w3-3',
|
||||
title: 'Percusión',
|
||||
subtitle: 'Attack rápido, decay corto',
|
||||
description: 'Los sonidos percusivos tienen un attack instantáneo y un decay corto sin sustain. Piensa en un tambor, un clic, un bleep — el sonido aparece de golpe y muere rápido. Configura un envelope percusivo.',
|
||||
concept: 'Attack muy bajo (~0.001s), Decay corto (~0.1-0.2s), Sustain a 0, Release corto. Esto crea un "blip" percusivo. Perfecto para hi-hats, kicks sintéticos, y bleeps 8-bit.',
|
||||
availableModules: ['envelope', 'keyboard'],
|
||||
preplacedModules: [
|
||||
{ id: 1, type: 'oscillator', x: 80, y: 60, params: { waveform: 'sine', frequency: 440, detune: 0 }, locked: true },
|
||||
{ id: 2, type: 'vca', x: 400, y: 60, params: { gain: 0 }, locked: false },
|
||||
{ id: 3, type: 'output', x: 620, y: 80, params: { volume: -6 }, locked: true },
|
||||
],
|
||||
target: {
|
||||
build: [
|
||||
{ type: 'oscillator', params: { waveform: 'sine', frequency: 440 } },
|
||||
],
|
||||
envelope: { attack: 0.005, decay: 0.15, sustain: 0, release: 0.1 },
|
||||
duration: 2,
|
||||
},
|
||||
checks: [
|
||||
{
|
||||
star: 1,
|
||||
name: 'Señal con envelope',
|
||||
desc: 'Osc → VCA → Out, con Envelope al CV y Keyboard al Gate',
|
||||
test: (mods, conns) => {
|
||||
const osc = mods.find(m => m.type === 'oscillator');
|
||||
const vca = mods.find(m => m.type === 'vca');
|
||||
const env = mods.find(m => m.type === 'envelope');
|
||||
const kb = mods.find(m => m.type === 'keyboard');
|
||||
const out = mods.find(m => m.type === 'output');
|
||||
if (!osc || !vca || !env || !kb || !out) return false;
|
||||
return conns.some(c => c.from.moduleId === osc.id && c.to.moduleId === vca.id) &&
|
||||
conns.some(c => c.from.moduleId === vca.id && c.to.moduleId === out.id) &&
|
||||
conns.some(c => c.from.moduleId === env.id && c.to.moduleId === vca.id && c.to.port === 'cv') &&
|
||||
conns.some(c => c.from.moduleId === kb.id && c.to.moduleId === env.id);
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 2,
|
||||
name: 'Sin sustain',
|
||||
desc: 'Sustain a 0 (o casi)',
|
||||
test: (mods) => {
|
||||
const env = mods.find(m => m.type === 'envelope');
|
||||
return env && (env.params.sustain ?? 0.5) < 0.05;
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 3,
|
||||
name: 'Blip perfecto',
|
||||
desc: 'Attack <0.01s, Decay 0.05-0.3s, Sustain ~0',
|
||||
test: (mods) => {
|
||||
const env = mods.find(m => m.type === 'envelope');
|
||||
if (!env) return false;
|
||||
return (env.params.attack ?? 0.01) < 0.015 &&
|
||||
(env.params.decay ?? 0.2) >= 0.05 && (env.params.decay ?? 0.2) <= 0.3 &&
|
||||
(env.params.sustain ?? 0.5) < 0.05;
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// ─────────────── LEVEL 3.4 ───────────────
|
||||
{
|
||||
id: 'w3-4',
|
||||
title: 'Pad Atmosférico',
|
||||
subtitle: 'Suave y envolvente',
|
||||
description: 'Los pads son sonidos largos y suaves que rellenan el fondo de una mezcla. Se consiguen con un attack lento (el sonido entra gradualmente), sustain alto, y release largo (se desvanece lentamente).',
|
||||
concept: 'Attack lento (~1-2s), Decay corto (~0.3s), Sustain alto (~0.7-0.9), Release largo (~2-4s). El sonido "respira" — entra suave y se queda flotando.',
|
||||
availableModules: ['envelope', 'keyboard'],
|
||||
preplacedModules: [
|
||||
{ id: 1, type: 'oscillator', x: 80, y: 60, params: { waveform: 'sawtooth', frequency: 220, detune: 0 }, locked: true },
|
||||
{ id: 2, type: 'vca', x: 400, y: 60, params: { gain: 0 }, locked: false },
|
||||
{ id: 3, type: 'output', x: 620, y: 80, params: { volume: -6 }, locked: true },
|
||||
],
|
||||
target: {
|
||||
build: [
|
||||
{ type: 'oscillator', params: { waveform: 'sawtooth', frequency: 220 } },
|
||||
],
|
||||
envelope: { attack: 1.2, decay: 0.3, sustain: 0.75, release: 2.5 },
|
||||
duration: 2,
|
||||
},
|
||||
checks: [
|
||||
{
|
||||
star: 1,
|
||||
name: 'Señal con envelope',
|
||||
desc: 'Osc → VCA → Out, Envelope al CV, Keyboard al Gate',
|
||||
test: (mods, conns) => {
|
||||
const env = mods.find(m => m.type === 'envelope');
|
||||
const vca = mods.find(m => m.type === 'vca');
|
||||
const kb = mods.find(m => m.type === 'keyboard');
|
||||
if (!env || !vca || !kb) return false;
|
||||
return conns.some(c => c.from.moduleId === env.id && c.to.moduleId === vca.id && c.to.port === 'cv') &&
|
||||
conns.some(c => c.from.moduleId === kb.id && c.to.moduleId === env.id);
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 2,
|
||||
name: 'Attack lento',
|
||||
desc: 'Attack mayor de 0.5 segundos',
|
||||
test: (mods) => {
|
||||
const env = mods.find(m => m.type === 'envelope');
|
||||
return env && (env.params.attack ?? 0.01) > 0.5;
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 3,
|
||||
name: 'Pad perfecto',
|
||||
desc: 'Attack >0.8s, Sustain >0.6, Release >1.5s',
|
||||
test: (mods) => {
|
||||
const env = mods.find(m => m.type === 'envelope');
|
||||
if (!env) return false;
|
||||
return (env.params.attack ?? 0.01) > 0.8 &&
|
||||
(env.params.sustain ?? 0.5) > 0.6 &&
|
||||
(env.params.release ?? 0.5) > 1.5;
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// ─────────────── LEVEL 3.5 ───────────────
|
||||
{
|
||||
id: 'w3-5',
|
||||
title: 'Pluck',
|
||||
subtitle: 'Cuerdas pulsadas',
|
||||
description: 'El sonido de una cuerda pulsada (guitarra, arpa) tiene un attack rápido y un decay medio. No tiene sustain real — el sonido decrece naturalmente. El filtro ayuda a que suene más natural.',
|
||||
concept: 'Envelope con Attack rápido (~0.001s), Decay medio (~0.4-0.8s), Sustain bajo (~0.1), Release ~0.3s. Usa una onda triangle o saw con un filtro lowpass para suavizar.',
|
||||
availableModules: ['envelope', 'keyboard', 'filter'],
|
||||
preplacedModules: [
|
||||
{ id: 1, type: 'oscillator', x: 80, y: 60, params: { waveform: 'triangle', frequency: 440, detune: 0 }, locked: false },
|
||||
{ id: 2, type: 'vca', x: 500, y: 60, params: { gain: 0 }, locked: false },
|
||||
{ id: 3, type: 'output', x: 760, y: 80, params: { volume: -6 }, locked: true },
|
||||
],
|
||||
target: {
|
||||
build: [
|
||||
{ type: 'oscillator', params: { waveform: 'triangle', frequency: 440 } },
|
||||
],
|
||||
filter: { type: 'lowpass', frequency: 3000, Q: 2 },
|
||||
envelope: { attack: 0.008, decay: 0.5, sustain: 0.05, release: 0.2 },
|
||||
duration: 2,
|
||||
},
|
||||
checks: [
|
||||
{
|
||||
star: 1,
|
||||
name: 'Cadena completa',
|
||||
desc: 'Osc → (Filter →) VCA → Out con Envelope y Keyboard',
|
||||
test: (mods, conns) => {
|
||||
const env = mods.find(m => m.type === 'envelope');
|
||||
const vca = mods.find(m => m.type === 'vca');
|
||||
const kb = mods.find(m => m.type === 'keyboard');
|
||||
const out = mods.find(m => m.type === 'output');
|
||||
if (!env || !vca || !kb || !out) return false;
|
||||
return conns.some(c => c.from.moduleId === vca.id && c.to.moduleId === out.id) &&
|
||||
conns.some(c => c.from.moduleId === env.id && c.to.port === 'cv') &&
|
||||
conns.some(c => c.from.moduleId === kb.id && c.to.port === 'gate');
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 2,
|
||||
name: 'Forma pluck',
|
||||
desc: 'Attack rápido (<0.02s), Sustain bajo (<0.2)',
|
||||
test: (mods) => {
|
||||
const env = mods.find(m => m.type === 'envelope');
|
||||
if (!env) return false;
|
||||
return (env.params.attack ?? 0.01) < 0.02 && (env.params.sustain ?? 0.5) < 0.2;
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 3,
|
||||
name: 'Pluck natural',
|
||||
desc: 'Pluck shape + filtro lowpass en la cadena',
|
||||
test: (mods, conns) => {
|
||||
const env = mods.find(m => m.type === 'envelope');
|
||||
const flt = mods.find(m => m.type === 'filter');
|
||||
if (!env || !flt) return false;
|
||||
return (env.params.attack ?? 0.01) < 0.02 &&
|
||||
(env.params.sustain ?? 0.5) < 0.2 &&
|
||||
(env.params.decay ?? 0.2) >= 0.3 &&
|
||||
flt.params.type === 'lowpass';
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// ─────────────── LEVEL 3.6 ───────────────
|
||||
{
|
||||
id: 'w3-6',
|
||||
title: 'Filtro Dinámico',
|
||||
subtitle: 'Envelope → Cutoff',
|
||||
description: 'Los envelopes no solo controlan volumen — ¡también pueden controlar el filtro! Conectar un envelope al cutoff crea sonidos que se "abren" y "cierran" con cada nota. Es la técnica más importante de síntesis sustractiva.',
|
||||
concept: 'Conecta un segundo Envelope a la entrada Cutoff del filtro. Keyboard → Gate de ambos envelopes. Un envelope controla volumen (VCA), otro controla brillo (filtro cutoff).',
|
||||
availableModules: ['envelope', 'keyboard', 'filter'],
|
||||
preplacedModules: [
|
||||
{ id: 1, type: 'oscillator', x: 60, y: 40, params: { waveform: 'sawtooth', frequency: 220, detune: 0 }, locked: true },
|
||||
{ id: 2, type: 'vca', x: 520, y: 40, params: { gain: 0 }, locked: false },
|
||||
{ id: 3, type: 'output', x: 760, y: 80, params: { volume: -6 }, locked: true },
|
||||
],
|
||||
target: {
|
||||
build: [
|
||||
{ type: 'oscillator', params: { waveform: 'sawtooth', frequency: 220 } },
|
||||
],
|
||||
filter: { type: 'lowpass', frequency: 800, Q: 4 },
|
||||
envelope: { attack: 0.01, decay: 0.3, sustain: 0.4, release: 0.2 },
|
||||
duration: 2,
|
||||
},
|
||||
checks: [
|
||||
{
|
||||
star: 1,
|
||||
name: 'Doble envelope',
|
||||
desc: 'Dos envelopes: uno al VCA, otro al filtro cutoff',
|
||||
test: (mods, conns) => {
|
||||
const envs = mods.filter(m => m.type === 'envelope');
|
||||
const vca = mods.find(m => m.type === 'vca');
|
||||
const flt = mods.find(m => m.type === 'filter');
|
||||
if (envs.length < 2 || !vca || !flt) return false;
|
||||
const envToVca = envs.some(e => conns.some(c => c.from.moduleId === e.id && c.to.moduleId === vca.id && c.to.port === 'cv'));
|
||||
const envToFlt = envs.some(e => conns.some(c => c.from.moduleId === e.id && c.to.moduleId === flt.id && c.to.port === 'cutoff'));
|
||||
return envToVca && envToFlt;
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 2,
|
||||
name: 'Gates conectados',
|
||||
desc: 'Keyboard → Gate de ambos envelopes',
|
||||
test: (mods, conns) => {
|
||||
const kb = mods.find(m => m.type === 'keyboard');
|
||||
const envs = mods.filter(m => m.type === 'envelope');
|
||||
if (!kb || envs.length < 2) return false;
|
||||
const gatedEnvs = envs.filter(e =>
|
||||
conns.some(c => c.from.moduleId === kb.id && c.to.moduleId === e.id && c.to.port === 'gate')
|
||||
);
|
||||
return gatedEnvs.length >= 2;
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 3,
|
||||
name: 'Envelopes distintos',
|
||||
desc: 'Los dos envelopes tienen decays diferentes (>0.1s diferencia)',
|
||||
test: (mods) => {
|
||||
const envs = mods.filter(m => m.type === 'envelope');
|
||||
if (envs.length < 2) return false;
|
||||
const decays = envs.map(e => e.params.decay ?? 0.2);
|
||||
return Math.abs(decays[0] - decays[1]) > 0.1;
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// ─────────────── LEVEL 3.7 ───────────────
|
||||
{
|
||||
id: 'w3-7',
|
||||
title: 'Tremolo',
|
||||
subtitle: 'LFO → Volumen',
|
||||
description: 'El tremolo es una variación rítmica del volumen. Se consigue conectando un LFO a la ganancia del VCA. Es un efecto clásico de guitarras, órganos y sintetizadores vintage.',
|
||||
concept: 'Conecta un LFO a la entrada CV del VCA (no del filtro). Un LFO a ~4-8 Hz con amplitud moderada crea un tremolo clásico. Más lento (~1-2 Hz) suena como un "pulso".',
|
||||
availableModules: ['lfo'],
|
||||
preplacedModules: [
|
||||
{ id: 1, type: 'oscillator', x: 80, y: 60, params: { waveform: 'sine', frequency: 440, detune: 0 }, locked: true },
|
||||
{ id: 2, type: 'vca', x: 340, y: 60, params: { gain: 0.7 }, locked: false },
|
||||
{ id: 3, type: 'output', x: 580, y: 80, params: { volume: -6 }, locked: true },
|
||||
],
|
||||
target: {
|
||||
build: [
|
||||
{ type: 'oscillator', params: { waveform: 'sine', frequency: 440 } },
|
||||
],
|
||||
lfo: { frequency: 6, type: 'sine', min: 0.2, max: 1.0, target: 'amplitude' },
|
||||
duration: 3,
|
||||
},
|
||||
checks: [
|
||||
{
|
||||
star: 1,
|
||||
name: 'Cadena básica',
|
||||
desc: 'Oscilador → VCA → Salida',
|
||||
test: (mods, conns) => {
|
||||
const osc = mods.find(m => m.type === 'oscillator');
|
||||
const vca = mods.find(m => m.type === 'vca');
|
||||
const out = mods.find(m => m.type === 'output');
|
||||
if (!osc || !vca || !out) return false;
|
||||
return conns.some(c => c.from.moduleId === osc.id && c.to.moduleId === vca.id) &&
|
||||
conns.some(c => c.from.moduleId === vca.id && c.to.moduleId === out.id);
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 2,
|
||||
name: 'LFO al VCA',
|
||||
desc: 'Conecta LFO → VCA (CV)',
|
||||
test: (mods, conns) => {
|
||||
const lfo = mods.find(m => m.type === 'lfo');
|
||||
const vca = mods.find(m => m.type === 'vca');
|
||||
if (!lfo || !vca) return false;
|
||||
return conns.some(c => c.from.moduleId === lfo.id && c.to.moduleId === vca.id && c.to.port === 'cv');
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 3,
|
||||
name: 'Tremolo rítmico',
|
||||
desc: 'LFO entre 3-10 Hz (tremolo audible)',
|
||||
test: (mods) => {
|
||||
const lfo = mods.find(m => m.type === 'lfo');
|
||||
if (!lfo) return false;
|
||||
const rate = lfo.params.frequency ?? 2;
|
||||
return rate >= 3 && rate <= 10;
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// ─────────────── LEVEL 3.8: BOSS ───────────────
|
||||
{
|
||||
id: 'w3-8',
|
||||
title: 'Synth Lead Completo',
|
||||
subtitle: 'BOSS: Ponlo todo junto',
|
||||
description: 'Es hora de construir un sonido de lead completo desde cero. Combina todo lo que has aprendido: oscilador, filtro con envelope, VCA con envelope, y keyboard para tocar. Es el patch clásico de síntesis sustractiva.',
|
||||
concept: 'Keyboard → Osc (freq) + Env1 (gate) + Env2 (gate). Osc → Filter → VCA → Output. Env1 → Filter cutoff (decay medio para "apertura"). Env2 → VCA cv (sustain para mantener). Ajusta para un lead expresivo.',
|
||||
availableModules: ['oscillator', 'filter', 'vca', 'envelope', 'keyboard'],
|
||||
preplacedModules: [
|
||||
{ id: 1, type: 'output', x: 800, y: 120, params: { volume: -6 }, locked: true },
|
||||
],
|
||||
target: {
|
||||
build: [
|
||||
{ type: 'oscillator', params: { waveform: 'sawtooth', frequency: 220 } },
|
||||
],
|
||||
filter: { type: 'lowpass', frequency: 2000, Q: 6 },
|
||||
envelope: { attack: 0.05, decay: 0.3, sustain: 0.5, release: 0.6 },
|
||||
duration: 3,
|
||||
},
|
||||
checks: [
|
||||
{
|
||||
star: 1,
|
||||
name: 'Cadena sustractiva',
|
||||
desc: 'Osc → Filter → VCA → Output',
|
||||
test: (mods, conns) => {
|
||||
const osc = mods.find(m => m.type === 'oscillator');
|
||||
const flt = mods.find(m => m.type === 'filter');
|
||||
const vca = mods.find(m => m.type === 'vca');
|
||||
const out = mods.find(m => m.type === 'output');
|
||||
if (!osc || !flt || !vca || !out) return false;
|
||||
return conns.some(c => c.from.moduleId === osc.id && c.to.moduleId === flt.id) &&
|
||||
conns.some(c => c.from.moduleId === flt.id && c.to.moduleId === vca.id) &&
|
||||
conns.some(c => c.from.moduleId === vca.id && c.to.moduleId === out.id);
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 2,
|
||||
name: 'Doble modulación',
|
||||
desc: 'Envelope al filtro cutoff Y envelope al VCA cv',
|
||||
test: (mods, conns) => {
|
||||
const envs = mods.filter(m => m.type === 'envelope');
|
||||
const flt = mods.find(m => m.type === 'filter');
|
||||
const vca = mods.find(m => m.type === 'vca');
|
||||
if (envs.length < 2 || !flt || !vca) return false;
|
||||
const envToFlt = envs.some(e => conns.some(c => c.from.moduleId === e.id && c.to.moduleId === flt.id && c.to.port === 'cutoff'));
|
||||
const envToVca = envs.some(e => conns.some(c => c.from.moduleId === e.id && c.to.moduleId === vca.id && c.to.port === 'cv'));
|
||||
return envToFlt && envToVca;
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 3,
|
||||
name: 'Lead expresivo',
|
||||
desc: 'Keyboard controla freq + gates, envelopes distintos',
|
||||
test: (mods, conns) => {
|
||||
const kb = mods.find(m => m.type === 'keyboard');
|
||||
const osc = mods.find(m => m.type === 'oscillator');
|
||||
const envs = mods.filter(m => m.type === 'envelope');
|
||||
if (!kb || !osc || envs.length < 2) return false;
|
||||
// KB → osc freq
|
||||
const kbFreq = conns.some(c => c.from.moduleId === kb.id && c.from.port === 'freq' && c.to.moduleId === osc.id);
|
||||
// KB → both env gates
|
||||
const gated = envs.filter(e =>
|
||||
conns.some(c => c.from.moduleId === kb.id && c.to.moduleId === e.id && c.to.port === 'gate')
|
||||
);
|
||||
// Envelopes have different settings
|
||||
const decays = envs.map(e => e.params.decay ?? 0.2);
|
||||
const diffDecay = Math.abs(decays[0] - decays[1]) > 0.05;
|
||||
return kbFreq && gated.length >= 2 && diffDecay;
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
529
packages/client/src/game/levels/world4.js
Normal file
529
packages/client/src/game/levels/world4.js
Normal file
@@ -0,0 +1,529 @@
|
||||
/**
|
||||
* World 4 — "Modulación" (Modulation)
|
||||
*
|
||||
* Teaches: LFO routing, vibrato, PWM, FM synthesis, ring modulation, complex patches
|
||||
* 8 levels, progressive difficulty
|
||||
*/
|
||||
|
||||
export const WORLD_4 = {
|
||||
id: 'w4',
|
||||
name: 'Modulación',
|
||||
subtitle: 'Haz que el sonido viva y respire',
|
||||
icon: '∿',
|
||||
color: '#ffcc00',
|
||||
unlockStars: 36,
|
||||
levels: [
|
||||
// ─────────────── LEVEL 4.1 ───────────────
|
||||
{
|
||||
id: 'w4-1',
|
||||
title: 'Vibrato',
|
||||
subtitle: 'LFO → Frecuencia',
|
||||
description: 'El vibrato es una oscilación sutil de la frecuencia. Cantantes, violinistas y sintetizadores lo usan para dar expresividad. Se consigue conectando un LFO lento a la frecuencia del oscilador.',
|
||||
concept: 'Conecta un LFO a la entrada "Freq" del oscilador. Un LFO a ~5-7 Hz con amplitud pequeña crea un vibrato natural. Demasiado rápido o amplio suena a sirena.',
|
||||
availableModules: ['lfo'],
|
||||
preplacedModules: [
|
||||
{ id: 1, type: 'oscillator', x: 80, y: 80, params: { waveform: 'sine', frequency: 440, detune: 0 }, locked: true },
|
||||
{ id: 2, type: 'output', x: 500, y: 100, params: { volume: -6 }, locked: true },
|
||||
],
|
||||
target: {
|
||||
build: [
|
||||
{ type: 'oscillator', params: { waveform: 'sine', frequency: 440 } },
|
||||
],
|
||||
lfo: { frequency: 6, type: 'sine', min: 420, max: 460, target: 'frequency' },
|
||||
duration: 3,
|
||||
},
|
||||
checks: [
|
||||
{
|
||||
star: 1,
|
||||
name: 'Señal básica',
|
||||
desc: 'Oscilador conectado a la salida',
|
||||
test: (mods, conns) => {
|
||||
const osc = mods.find(m => m.type === 'oscillator');
|
||||
const out = mods.find(m => m.type === 'output');
|
||||
return osc && out && conns.some(c => c.from.moduleId === osc.id && c.to.moduleId === out.id);
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 2,
|
||||
name: 'LFO a frecuencia',
|
||||
desc: 'Conecta LFO → Osc (Freq)',
|
||||
test: (mods, conns) => {
|
||||
const lfo = mods.find(m => m.type === 'lfo');
|
||||
const osc = mods.find(m => m.type === 'oscillator');
|
||||
if (!lfo || !osc) return false;
|
||||
return conns.some(c => c.from.moduleId === lfo.id && c.to.moduleId === osc.id && c.to.port === 'freq');
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 3,
|
||||
name: 'Vibrato musical',
|
||||
desc: 'LFO entre 4-8 Hz (vibrato natural)',
|
||||
test: (mods) => {
|
||||
const lfo = mods.find(m => m.type === 'lfo');
|
||||
if (!lfo) return false;
|
||||
const rate = lfo.params.frequency ?? 2;
|
||||
return rate >= 4 && rate <= 8;
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// ─────────────── LEVEL 4.2 ───────────────
|
||||
{
|
||||
id: 'w4-2',
|
||||
title: 'Sirena',
|
||||
subtitle: 'LFO lento = pitch sweep',
|
||||
description: 'Cuando el LFO es muy lento y con mucha amplitud, el vibrato se convierte en un barrido de frecuencia — como una sirena. Los DJs y productores usan este efecto para crear tensión y transiciones.',
|
||||
concept: 'Usa un LFO muy lento (~0.2-0.5 Hz) con forma de onda sine o triangle conectado a la frecuencia del oscilador. La velocidad lenta crea un sweep dramático arriba y abajo.',
|
||||
availableModules: ['lfo'],
|
||||
preplacedModules: [
|
||||
{ id: 1, type: 'oscillator', x: 80, y: 80, params: { waveform: 'square', frequency: 440, detune: 0 }, locked: false },
|
||||
{ id: 2, type: 'output', x: 500, y: 100, params: { volume: -6 }, locked: true },
|
||||
],
|
||||
target: {
|
||||
build: [
|
||||
{ type: 'oscillator', params: { waveform: 'square', frequency: 440 } },
|
||||
],
|
||||
lfo: { frequency: 0.3, type: 'sine', min: 200, max: 800, target: 'frequency' },
|
||||
duration: 4,
|
||||
},
|
||||
checks: [
|
||||
{
|
||||
star: 1,
|
||||
name: 'LFO conectado',
|
||||
desc: 'LFO → Osc (Freq) → Output',
|
||||
test: (mods, conns) => {
|
||||
const lfo = mods.find(m => m.type === 'lfo');
|
||||
const osc = mods.find(m => m.type === 'oscillator');
|
||||
if (!lfo || !osc) return false;
|
||||
return conns.some(c => c.from.moduleId === lfo.id && c.to.moduleId === osc.id && c.to.port === 'freq');
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 2,
|
||||
name: 'Sweep lento',
|
||||
desc: 'LFO por debajo de 1 Hz',
|
||||
test: (mods) => {
|
||||
const lfo = mods.find(m => m.type === 'lfo');
|
||||
return lfo && (lfo.params.frequency ?? 2) < 1;
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 3,
|
||||
name: 'Sirena perfecta',
|
||||
desc: 'LFO 0.1-0.5 Hz, onda sine o triangle',
|
||||
test: (mods) => {
|
||||
const lfo = mods.find(m => m.type === 'lfo');
|
||||
if (!lfo) return false;
|
||||
const rate = lfo.params.frequency ?? 2;
|
||||
const wave = lfo.params.waveform ?? 'sine';
|
||||
return rate >= 0.1 && rate <= 0.5 && (wave === 'sine' || wave === 'triangle');
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// ─────────────── LEVEL 4.3 ───────────────
|
||||
{
|
||||
id: 'w4-3',
|
||||
title: 'Wah-Wah Rítmico',
|
||||
subtitle: 'LFO square → Cutoff',
|
||||
description: 'Un LFO con onda cuadrada crea cambios bruscos en el cutoff del filtro — el filtro salta entre abierto y cerrado. Es un efecto rítmico perfecto para música electrónica y funk.',
|
||||
concept: 'LFO square a ~2-4 Hz conectado al cutoff del filtro. La onda cuadrada crea un on/off rítmico. Ajusta el cutoff base del filtro y la resonancia para darle más carácter.',
|
||||
availableModules: ['lfo'],
|
||||
preplacedModules: [
|
||||
{ id: 1, type: 'oscillator', x: 60, y: 60, params: { waveform: 'sawtooth', frequency: 110, detune: 0 }, locked: true },
|
||||
{ id: 2, type: 'filter', x: 300, y: 60, params: { type: 'lowpass', frequency: 600, Q: 4 }, locked: false },
|
||||
{ id: 3, type: 'output', x: 560, y: 80, params: { volume: -8 }, locked: true },
|
||||
],
|
||||
target: {
|
||||
build: [
|
||||
{ type: 'oscillator', params: { waveform: 'sawtooth', frequency: 110 } },
|
||||
],
|
||||
filter: { type: 'lowpass', frequency: 800, Q: 5 },
|
||||
lfo: { frequency: 3, type: 'square', min: 400, max: 4000, target: 'frequency' },
|
||||
duration: 3,
|
||||
},
|
||||
checks: [
|
||||
{
|
||||
star: 1,
|
||||
name: 'Cadena con LFO',
|
||||
desc: 'Osc → Filter → Out, LFO al Cutoff',
|
||||
test: (mods, conns) => {
|
||||
const osc = mods.find(m => m.type === 'oscillator');
|
||||
const flt = mods.find(m => m.type === 'filter');
|
||||
const lfo = mods.find(m => m.type === 'lfo');
|
||||
const out = mods.find(m => m.type === 'output');
|
||||
if (!osc || !flt || !lfo || !out) return false;
|
||||
return conns.some(c => c.from.moduleId === osc.id && c.to.moduleId === flt.id) &&
|
||||
conns.some(c => c.from.moduleId === flt.id && c.to.moduleId === out.id) &&
|
||||
conns.some(c => c.from.moduleId === lfo.id && c.to.moduleId === flt.id && c.to.port === 'cutoff');
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 2,
|
||||
name: 'LFO cuadrado',
|
||||
desc: 'LFO con onda square',
|
||||
test: (mods) => {
|
||||
const lfo = mods.find(m => m.type === 'lfo');
|
||||
return lfo && (lfo.params.waveform ?? 'sine') === 'square';
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 3,
|
||||
name: 'Wah rítmico',
|
||||
desc: 'LFO square a 2-4 Hz, filtro con Q > 3',
|
||||
test: (mods) => {
|
||||
const lfo = mods.find(m => m.type === 'lfo');
|
||||
const flt = mods.find(m => m.type === 'filter');
|
||||
if (!lfo || !flt) return false;
|
||||
const rate = lfo.params.frequency ?? 2;
|
||||
return (lfo.params.waveform ?? 'sine') === 'square' &&
|
||||
rate >= 2 && rate <= 4 && (flt.params.Q ?? 1) > 3;
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// ─────────────── LEVEL 4.4 ───────────────
|
||||
{
|
||||
id: 'w4-4',
|
||||
title: 'Auto-Pan',
|
||||
subtitle: 'Sonido en movimiento',
|
||||
description: 'Conectar LFOs a los niveles de un mixer permite mover el sonido entre canales. Si envías el mismo oscilador al Left y Right con LFOs invertidos, el sonido viaja de un altavoz al otro.',
|
||||
concept: 'Conecta el oscilador al output con dos cables (Left y Right). Añade un LFO que module algo para crear movimiento estéreo. El efecto auto-pan crea una sensación de espacio.',
|
||||
availableModules: ['lfo', 'vca', 'mixer'],
|
||||
preplacedModules: [
|
||||
{ id: 1, type: 'oscillator', x: 80, y: 80, params: { waveform: 'sine', frequency: 440, detune: 0 }, locked: true },
|
||||
{ id: 2, type: 'output', x: 600, y: 100, params: { volume: -6 }, locked: true },
|
||||
],
|
||||
target: {
|
||||
build: [
|
||||
{ type: 'oscillator', params: { waveform: 'sine', frequency: 440 } },
|
||||
],
|
||||
lfo: { frequency: 2, type: 'sine', min: 0.3, max: 1.0, target: 'amplitude' },
|
||||
duration: 3,
|
||||
},
|
||||
checks: [
|
||||
{
|
||||
star: 1,
|
||||
name: 'Estéreo',
|
||||
desc: 'Oscilador conectado a ambos canales (Left + Right)',
|
||||
test: (mods, conns) => {
|
||||
const osc = mods.find(m => m.type === 'oscillator');
|
||||
const out = mods.find(m => m.type === 'output');
|
||||
if (!osc || !out) return false;
|
||||
// Direct or through other modules to both channels
|
||||
const toLeft = conns.some(c => c.to.moduleId === out.id && c.to.port === 'left');
|
||||
const toRight = conns.some(c => c.to.moduleId === out.id && c.to.port === 'right');
|
||||
return toLeft && toRight;
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 2,
|
||||
name: 'LFO presente',
|
||||
desc: 'Hay al menos un LFO conectado',
|
||||
test: (mods, conns) => {
|
||||
const lfo = mods.find(m => m.type === 'lfo');
|
||||
if (!lfo) return false;
|
||||
return conns.some(c => c.from.moduleId === lfo.id);
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 3,
|
||||
name: 'Modulación estéreo',
|
||||
desc: 'LFO modula VCA(s) en la cadena estéreo',
|
||||
test: (mods, conns) => {
|
||||
const lfo = mods.find(m => m.type === 'lfo');
|
||||
const vcas = mods.filter(m => m.type === 'vca');
|
||||
if (!lfo || vcas.length < 1) return false;
|
||||
return vcas.some(v => conns.some(c => c.from.moduleId === lfo.id && c.to.moduleId === v.id));
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// ─────────────── LEVEL 4.5 ───────────────
|
||||
{
|
||||
id: 'w4-5',
|
||||
title: 'Doble Modulación',
|
||||
subtitle: 'LFO al filter + LFO al VCA',
|
||||
description: 'Un solo LFO puede modular múltiples destinos a la vez. Conectar el mismo LFO al cutoff del filtro y al gain del VCA crea un sonido que se abre y se hace más fuerte simultáneamente — un efecto muy dinámico.',
|
||||
concept: 'Usa un LFO y conéctalo tanto al Cutoff del filtro como al CV del VCA. El mismo movimiento cíclico afecta brillo y volumen a la vez. Ajusta ~2-3 Hz.',
|
||||
availableModules: ['lfo'],
|
||||
preplacedModules: [
|
||||
{ id: 1, type: 'oscillator', x: 60, y: 40, params: { waveform: 'sawtooth', frequency: 110, detune: 0 }, locked: true },
|
||||
{ id: 2, type: 'filter', x: 280, y: 40, params: { type: 'lowpass', frequency: 800, Q: 5 }, locked: false },
|
||||
{ id: 3, type: 'vca', x: 480, y: 40, params: { gain: 0.6 }, locked: false },
|
||||
{ id: 4, type: 'output', x: 680, y: 60, params: { volume: -8 }, locked: true },
|
||||
],
|
||||
target: {
|
||||
build: [
|
||||
{ type: 'oscillator', params: { waveform: 'sawtooth', frequency: 110 } },
|
||||
],
|
||||
filter: { type: 'lowpass', frequency: 1200, Q: 6 },
|
||||
lfo: { frequency: 2.5, type: 'sine', min: 400, max: 3500, target: 'frequency' },
|
||||
duration: 3,
|
||||
},
|
||||
checks: [
|
||||
{
|
||||
star: 1,
|
||||
name: 'Cadena completa',
|
||||
desc: 'Osc → Filter → VCA → Output',
|
||||
test: (mods, conns) => {
|
||||
const osc = mods.find(m => m.type === 'oscillator');
|
||||
const flt = mods.find(m => m.type === 'filter');
|
||||
const vca = mods.find(m => m.type === 'vca');
|
||||
const out = mods.find(m => m.type === 'output');
|
||||
if (!osc || !flt || !vca || !out) return false;
|
||||
return conns.some(c => c.from.moduleId === osc.id && c.to.moduleId === flt.id) &&
|
||||
conns.some(c => c.from.moduleId === flt.id && c.to.moduleId === vca.id) &&
|
||||
conns.some(c => c.from.moduleId === vca.id && c.to.moduleId === out.id);
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 2,
|
||||
name: 'LFO a dos destinos',
|
||||
desc: 'Un LFO conectado al Cutoff Y al CV',
|
||||
test: (mods, conns) => {
|
||||
const lfo = mods.find(m => m.type === 'lfo');
|
||||
const flt = mods.find(m => m.type === 'filter');
|
||||
const vca = mods.find(m => m.type === 'vca');
|
||||
if (!lfo || !flt || !vca) return false;
|
||||
return conns.some(c => c.from.moduleId === lfo.id && c.to.moduleId === flt.id && c.to.port === 'cutoff') &&
|
||||
conns.some(c => c.from.moduleId === lfo.id && c.to.moduleId === vca.id && c.to.port === 'cv');
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 3,
|
||||
name: 'Pulso rítmico',
|
||||
desc: 'LFO a 1-4 Hz, filtro resonante (Q > 4)',
|
||||
test: (mods) => {
|
||||
const lfo = mods.find(m => m.type === 'lfo');
|
||||
const flt = mods.find(m => m.type === 'filter');
|
||||
if (!lfo || !flt) return false;
|
||||
const rate = lfo.params.frequency ?? 2;
|
||||
return rate >= 1 && rate <= 4 && (flt.params.Q ?? 1) > 4;
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// ─────────────── LEVEL 4.6 ───────────────
|
||||
{
|
||||
id: 'w4-6',
|
||||
title: 'Cross-Modulation',
|
||||
subtitle: 'Oscilador modula oscilador',
|
||||
description: 'Cuando un oscilador modula la frecuencia de otro oscilador a velocidades audibles (>20 Hz), se crea FM synthesis — timbres metálicos, campanas, y texturas inarmónicas que no puedes conseguir de otra forma.',
|
||||
concept: 'Conecta la salida de un oscilador a la entrada "Freq" de otro. Si el modulador está a frecuencia audible (>50 Hz), se crea FM. Frequencies bajas = vibrato, altas = nuevos timbres.',
|
||||
availableModules: ['oscillator', 'mixer'],
|
||||
preplacedModules: [
|
||||
{ id: 1, type: 'output', x: 600, y: 120, params: { volume: -8 }, locked: true },
|
||||
],
|
||||
target: {
|
||||
build: [
|
||||
{ type: 'oscillator', params: { waveform: 'sine', frequency: 220 } },
|
||||
{ type: 'oscillator', params: { waveform: 'sine', frequency: 880 } },
|
||||
],
|
||||
duration: 3,
|
||||
},
|
||||
checks: [
|
||||
{
|
||||
star: 1,
|
||||
name: 'Dos osciladores',
|
||||
desc: 'Al menos 2 osciladores con uno modulando al otro',
|
||||
test: (mods, conns) => {
|
||||
const oscs = mods.filter(m => m.type === 'oscillator');
|
||||
if (oscs.length < 2) return false;
|
||||
// One osc connected to another osc's freq
|
||||
return oscs.some(o1 => oscs.some(o2 =>
|
||||
o1.id !== o2.id && conns.some(c =>
|
||||
c.from.moduleId === o1.id && c.to.moduleId === o2.id && c.to.port === 'freq'
|
||||
)
|
||||
));
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 2,
|
||||
name: 'Sonido audible',
|
||||
desc: 'El oscilador portador está conectado a la salida',
|
||||
test: (mods, conns) => {
|
||||
const out = mods.find(m => m.type === 'output');
|
||||
if (!out) return false;
|
||||
// Something reaches the output
|
||||
return conns.some(c => c.to.moduleId === out.id);
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 3,
|
||||
name: 'FM metálico',
|
||||
desc: 'Modulador > 50 Hz (crea timbres FM reales)',
|
||||
test: (mods, conns) => {
|
||||
const oscs = mods.filter(m => m.type === 'oscillator');
|
||||
if (oscs.length < 2) return false;
|
||||
// Find modulator: osc that connects to another osc's freq
|
||||
for (const o1 of oscs) {
|
||||
for (const o2 of oscs) {
|
||||
if (o1.id !== o2.id) {
|
||||
const isModulating = conns.some(c =>
|
||||
c.from.moduleId === o1.id && c.to.moduleId === o2.id && c.to.port === 'freq'
|
||||
);
|
||||
if (isModulating && (o1.params.frequency ?? 440) > 50) return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// ─────────────── LEVEL 4.7 ───────────────
|
||||
{
|
||||
id: 'w4-7',
|
||||
title: 'Modulación Compleja',
|
||||
subtitle: 'Multi-destino',
|
||||
description: 'Los sintetizadores modulares brillan cuando conectas múltiples fuentes de modulación a múltiples destinos. Un LFO al filtro, un envelope al VCA, el keyboard a la frecuencia — cada conexión añade expresividad.',
|
||||
concept: 'Construye un patch con: Keyboard → Osc freq + Env gate. LFO → Filter cutoff. Envelope → VCA cv. Cada fuente de modulación controla un aspecto diferente del sonido.',
|
||||
availableModules: ['lfo', 'envelope', 'keyboard'],
|
||||
preplacedModules: [
|
||||
{ id: 1, type: 'oscillator', x: 60, y: 40, params: { waveform: 'sawtooth', frequency: 220, detune: 0 }, locked: false },
|
||||
{ id: 2, type: 'filter', x: 280, y: 40, params: { type: 'lowpass', frequency: 1000, Q: 4 }, locked: false },
|
||||
{ id: 3, type: 'vca', x: 480, y: 40, params: { gain: 0 }, locked: false },
|
||||
{ id: 4, type: 'output', x: 680, y: 60, params: { volume: -6 }, locked: true },
|
||||
],
|
||||
target: {
|
||||
build: [
|
||||
{ type: 'oscillator', params: { waveform: 'sawtooth', frequency: 220 } },
|
||||
],
|
||||
filter: { type: 'lowpass', frequency: 1500, Q: 5 },
|
||||
envelope: { attack: 0.1, decay: 0.3, sustain: 0.5, release: 0.4 },
|
||||
lfo: { frequency: 3, type: 'sine', min: 600, max: 3000, target: 'frequency' },
|
||||
duration: 3,
|
||||
},
|
||||
checks: [
|
||||
{
|
||||
star: 1,
|
||||
name: 'Cadena sustractiva',
|
||||
desc: 'Osc → Filter → VCA → Output',
|
||||
test: (mods, conns) => {
|
||||
const osc = mods.find(m => m.type === 'oscillator');
|
||||
const flt = mods.find(m => m.type === 'filter');
|
||||
const vca = mods.find(m => m.type === 'vca');
|
||||
const out = mods.find(m => m.type === 'output');
|
||||
if (!osc || !flt || !vca || !out) return false;
|
||||
return conns.some(c => c.from.moduleId === osc.id && c.to.moduleId === flt.id) &&
|
||||
conns.some(c => c.from.moduleId === flt.id && c.to.moduleId === vca.id) &&
|
||||
conns.some(c => c.from.moduleId === vca.id && c.to.moduleId === out.id);
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 2,
|
||||
name: 'Tres moduladores',
|
||||
desc: 'LFO, Envelope y Keyboard todos conectados',
|
||||
test: (mods, conns) => {
|
||||
const lfo = mods.find(m => m.type === 'lfo');
|
||||
const env = mods.find(m => m.type === 'envelope');
|
||||
const kb = mods.find(m => m.type === 'keyboard');
|
||||
if (!lfo || !env || !kb) return false;
|
||||
const lfoConn = conns.some(c => c.from.moduleId === lfo.id);
|
||||
const envConn = conns.some(c => c.from.moduleId === env.id);
|
||||
const kbConn = conns.some(c => c.from.moduleId === kb.id);
|
||||
return lfoConn && envConn && kbConn;
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 3,
|
||||
name: 'Routing correcto',
|
||||
desc: 'KB→freq, LFO→cutoff, Env→VCA cv, KB→gate',
|
||||
test: (mods, conns) => {
|
||||
const lfo = mods.find(m => m.type === 'lfo');
|
||||
const env = mods.find(m => m.type === 'envelope');
|
||||
const kb = mods.find(m => m.type === 'keyboard');
|
||||
const osc = mods.find(m => m.type === 'oscillator');
|
||||
const flt = mods.find(m => m.type === 'filter');
|
||||
const vca = mods.find(m => m.type === 'vca');
|
||||
if (!lfo || !env || !kb || !osc || !flt || !vca) return false;
|
||||
return conns.some(c => c.from.moduleId === kb.id && c.to.moduleId === osc.id && c.to.port === 'freq') &&
|
||||
conns.some(c => c.from.moduleId === lfo.id && c.to.moduleId === flt.id && c.to.port === 'cutoff') &&
|
||||
conns.some(c => c.from.moduleId === env.id && c.to.moduleId === vca.id && c.to.port === 'cv') &&
|
||||
conns.some(c => c.from.moduleId === kb.id && c.to.moduleId === env.id && c.to.port === 'gate');
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// ─────────────── LEVEL 4.8: BOSS ───────────────
|
||||
{
|
||||
id: 'w4-8',
|
||||
title: 'Dubstep Wobble',
|
||||
subtitle: 'BOSS: El bajo que wobbles',
|
||||
description: 'El wobble bass de dubstep es modulación pura: un oscilador grave con un filtro lowpass resonante modulado por un LFO. Añade un envelope para el ataque y tienes el sonido que definió un género.',
|
||||
concept: 'Osc saw grave (~55 Hz) → Filter LP resonante → VCA → Output. LFO (~1-3 Hz) → Filter cutoff. Envelope → VCA cv. Keyboard → gate + freq. Q alta (~10+) para ese sonido agresivo.',
|
||||
availableModules: ['oscillator', 'filter', 'vca', 'lfo', 'envelope', 'keyboard'],
|
||||
preplacedModules: [
|
||||
{ id: 1, type: 'output', x: 800, y: 120, params: { volume: -8 }, locked: true },
|
||||
],
|
||||
target: {
|
||||
build: [
|
||||
{ type: 'oscillator', params: { waveform: 'sawtooth', frequency: 55 } },
|
||||
],
|
||||
filter: { type: 'lowpass', frequency: 400, Q: 10 },
|
||||
envelope: { attack: 0.02, decay: 0.2, sustain: 0.7, release: 0.3 },
|
||||
lfo: { frequency: 1.5, type: 'sine', min: 200, max: 2000, target: 'frequency' },
|
||||
duration: 4,
|
||||
},
|
||||
checks: [
|
||||
{
|
||||
star: 1,
|
||||
name: 'Cadena con modulación',
|
||||
desc: 'Osc → Filter → VCA → Output con LFO al cutoff',
|
||||
test: (mods, conns) => {
|
||||
const osc = mods.find(m => m.type === 'oscillator');
|
||||
const flt = mods.find(m => m.type === 'filter');
|
||||
const vca = mods.find(m => m.type === 'vca');
|
||||
const lfo = mods.find(m => m.type === 'lfo');
|
||||
const out = mods.find(m => m.type === 'output');
|
||||
if (!osc || !flt || !vca || !lfo || !out) return false;
|
||||
return conns.some(c => c.from.moduleId === osc.id && c.to.moduleId === flt.id) &&
|
||||
conns.some(c => c.from.moduleId === flt.id && c.to.moduleId === vca.id) &&
|
||||
conns.some(c => c.from.moduleId === vca.id && c.to.moduleId === out.id) &&
|
||||
conns.some(c => c.from.moduleId === lfo.id && c.to.moduleId === flt.id && c.to.port === 'cutoff');
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 2,
|
||||
name: 'Wobble bass',
|
||||
desc: 'Osc grave (<130 Hz), LFO lento (1-3 Hz), Q > 8',
|
||||
test: (mods) => {
|
||||
const osc = mods.find(m => m.type === 'oscillator');
|
||||
const flt = mods.find(m => m.type === 'filter');
|
||||
const lfo = mods.find(m => m.type === 'lfo');
|
||||
if (!osc || !flt || !lfo) return false;
|
||||
return (osc.params.frequency ?? 440) < 130 &&
|
||||
(lfo.params.frequency ?? 2) >= 1 && (lfo.params.frequency ?? 2) <= 3 &&
|
||||
(flt.params.Q ?? 1) > 8;
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 3,
|
||||
name: 'Wobble completo',
|
||||
desc: 'Todo lo anterior + Envelope al VCA + Keyboard al gate',
|
||||
test: (mods, conns) => {
|
||||
const osc = mods.find(m => m.type === 'oscillator');
|
||||
const flt = mods.find(m => m.type === 'filter');
|
||||
const lfo = mods.find(m => m.type === 'lfo');
|
||||
const env = mods.find(m => m.type === 'envelope');
|
||||
const vca = mods.find(m => m.type === 'vca');
|
||||
const kb = mods.find(m => m.type === 'keyboard');
|
||||
if (!osc || !flt || !lfo || !env || !vca || !kb) return false;
|
||||
return (osc.params.frequency ?? 440) < 130 &&
|
||||
(flt.params.Q ?? 1) > 8 &&
|
||||
conns.some(c => c.from.moduleId === env.id && c.to.moduleId === vca.id && c.to.port === 'cv') &&
|
||||
conns.some(c => c.from.moduleId === kb.id && c.to.moduleId === env.id && c.to.port === 'gate');
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
519
packages/client/src/game/levels/world5.js
Normal file
519
packages/client/src/game/levels/world5.js
Normal file
@@ -0,0 +1,519 @@
|
||||
/**
|
||||
* World 5 — "Efectos" (Effects)
|
||||
*
|
||||
* Teaches: delay, reverb, distortion, effect chains, wet/dry mixing
|
||||
* 8 levels, progressive difficulty
|
||||
*/
|
||||
|
||||
export const WORLD_5 = {
|
||||
id: 'w5',
|
||||
name: 'Efectos',
|
||||
subtitle: 'Transforma el sonido con efectos',
|
||||
icon: '◈',
|
||||
color: '#44ff88',
|
||||
unlockStars: 48,
|
||||
levels: [
|
||||
// ─────────────── LEVEL 5.1 ───────────────
|
||||
{
|
||||
id: 'w5-1',
|
||||
title: 'El Eco',
|
||||
subtitle: 'Delay básico',
|
||||
description: 'El delay repite el sonido después de un tiempo. Es como gritar en un cañón y escuchar tu voz rebotando. El delay más simple tiene un tiempo de repetición y un feedback que controla cuántas veces se repite.',
|
||||
concept: 'Conecta: Oscilador → Delay → Output. El knob "Time" controla el tiempo entre repeticiones. El "Feedback" controla cuántas repeticiones. Empieza con un feedback bajo (~0.3).',
|
||||
availableModules: [],
|
||||
preplacedModules: [
|
||||
{ id: 1, type: 'oscillator', x: 80, y: 80, params: { waveform: 'sine', frequency: 440, detune: 0 }, locked: true },
|
||||
{ id: 2, type: 'delay', x: 340, y: 80, params: { time: 0.3, feedback: 0.3, mix: 0.5 }, locked: false },
|
||||
{ id: 3, type: 'output', x: 600, y: 100, params: { volume: -6 }, locked: true },
|
||||
],
|
||||
target: {
|
||||
build: [
|
||||
{ type: 'oscillator', params: { waveform: 'sine', frequency: 440 } },
|
||||
],
|
||||
effects: [
|
||||
{ type: 'delay', time: 0.35, feedback: 0.4, wet: 0.6 },
|
||||
],
|
||||
duration: 3,
|
||||
},
|
||||
checks: [
|
||||
{
|
||||
star: 1,
|
||||
name: 'Delay conectado',
|
||||
desc: 'Oscilador → Delay → Salida',
|
||||
test: (mods, conns) => {
|
||||
const osc = mods.find(m => m.type === 'oscillator');
|
||||
const del = mods.find(m => m.type === 'delay');
|
||||
const out = mods.find(m => m.type === 'output');
|
||||
if (!osc || !del || !out) return false;
|
||||
return conns.some(c => c.from.moduleId === osc.id && c.to.moduleId === del.id) &&
|
||||
conns.some(c => c.from.moduleId === del.id && c.to.moduleId === out.id);
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 2,
|
||||
name: 'Repeticiones',
|
||||
desc: 'Feedback por encima de 0.2',
|
||||
test: (mods) => {
|
||||
const del = mods.find(m => m.type === 'delay');
|
||||
return del && (del.params.feedback ?? 0) > 0.2;
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 3,
|
||||
name: 'Eco rítmico',
|
||||
desc: 'Delay time 0.2-0.5s, feedback 0.3-0.6',
|
||||
test: (mods) => {
|
||||
const del = mods.find(m => m.type === 'delay');
|
||||
if (!del) return false;
|
||||
const t = del.params.time ?? 0.3;
|
||||
const fb = del.params.feedback ?? 0;
|
||||
return t >= 0.2 && t <= 0.5 && fb >= 0.3 && fb <= 0.6;
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// ─────────────── LEVEL 5.2 ───────────────
|
||||
{
|
||||
id: 'w5-2',
|
||||
title: 'Slapback',
|
||||
subtitle: 'El delay rockabilly',
|
||||
description: 'El slapback es un delay muy corto (50-120ms) con una sola repetición. Es el efecto clásico de las voces de Elvis y el rockabilly — da presencia sin crear un eco largo.',
|
||||
concept: 'Delay con tiempo corto (~0.05-0.12s) y feedback muy bajo (~0.1 o menos). Una sola repetición rápida. El mix controla cuánto delay se mezcla con la señal original.',
|
||||
availableModules: ['delay'],
|
||||
preplacedModules: [
|
||||
{ id: 1, type: 'oscillator', x: 80, y: 80, params: { waveform: 'square', frequency: 330, detune: 0 }, locked: true },
|
||||
{ id: 2, type: 'output', x: 600, y: 100, params: { volume: -6 }, locked: true },
|
||||
],
|
||||
target: {
|
||||
build: [
|
||||
{ type: 'oscillator', params: { waveform: 'square', frequency: 330 } },
|
||||
],
|
||||
effects: [
|
||||
{ type: 'delay', time: 0.08, feedback: 0.05, wet: 0.5 },
|
||||
],
|
||||
duration: 2,
|
||||
},
|
||||
checks: [
|
||||
{
|
||||
star: 1,
|
||||
name: 'Delay en la cadena',
|
||||
desc: 'Osc → Delay → Salida',
|
||||
test: (mods, conns) => {
|
||||
const osc = mods.find(m => m.type === 'oscillator');
|
||||
const del = mods.find(m => m.type === 'delay');
|
||||
const out = mods.find(m => m.type === 'output');
|
||||
if (!osc || !del || !out) return false;
|
||||
return conns.some(c => c.from.moduleId === osc.id && c.to.moduleId === del.id) &&
|
||||
conns.some(c => c.from.moduleId === del.id && c.to.moduleId === out.id);
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 2,
|
||||
name: 'Tiempo corto',
|
||||
desc: 'Delay time menor de 0.15s',
|
||||
test: (mods) => {
|
||||
const del = mods.find(m => m.type === 'delay');
|
||||
return del && (del.params.time ?? 0.3) < 0.15;
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 3,
|
||||
name: 'Slapback perfecto',
|
||||
desc: 'Time 0.05-0.12s, feedback < 0.15',
|
||||
test: (mods) => {
|
||||
const del = mods.find(m => m.type === 'delay');
|
||||
if (!del) return false;
|
||||
const t = del.params.time ?? 0.3;
|
||||
const fb = del.params.feedback ?? 0.3;
|
||||
return t >= 0.05 && t <= 0.12 && fb < 0.15;
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// ─────────────── LEVEL 5.3 ───────────────
|
||||
{
|
||||
id: 'w5-3',
|
||||
title: 'Reverb Espacial',
|
||||
subtitle: 'El sonido del espacio',
|
||||
description: 'La reverb simula el sonido de un espacio acústico — desde una habitación pequeña hasta una catedral enorme. Es quizás el efecto más usado en toda la producción musical.',
|
||||
concept: 'Conecta: Oscilador → Reverb → Output. El knob de "decay" (o room size) controla el tamaño del espacio. Más largo = catedral. Más corto = habitación pequeña.',
|
||||
availableModules: [],
|
||||
preplacedModules: [
|
||||
{ id: 1, type: 'oscillator', x: 80, y: 80, params: { waveform: 'triangle', frequency: 440, detune: 0 }, locked: true },
|
||||
{ id: 2, type: 'reverb', x: 340, y: 80, params: { decay: 2, mix: 0.4 }, locked: false },
|
||||
{ id: 3, type: 'output', x: 600, y: 100, params: { volume: -6 }, locked: true },
|
||||
],
|
||||
target: {
|
||||
build: [
|
||||
{ type: 'oscillator', params: { waveform: 'triangle', frequency: 440 } },
|
||||
],
|
||||
effects: [
|
||||
{ type: 'reverb', decay: 5.5, wet: 0.55 },
|
||||
],
|
||||
duration: 3,
|
||||
},
|
||||
checks: [
|
||||
{
|
||||
star: 1,
|
||||
name: 'Reverb conectada',
|
||||
desc: 'Oscilador → Reverb → Salida',
|
||||
test: (mods, conns) => {
|
||||
const osc = mods.find(m => m.type === 'oscillator');
|
||||
const rev = mods.find(m => m.type === 'reverb');
|
||||
const out = mods.find(m => m.type === 'output');
|
||||
if (!osc || !rev || !out) return false;
|
||||
return conns.some(c => c.from.moduleId === osc.id && c.to.moduleId === rev.id) &&
|
||||
conns.some(c => c.from.moduleId === rev.id && c.to.moduleId === out.id);
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 2,
|
||||
name: 'Espacio grande',
|
||||
desc: 'Decay mayor de 3 segundos',
|
||||
test: (mods) => {
|
||||
const rev = mods.find(m => m.type === 'reverb');
|
||||
return rev && (rev.params.decay ?? 2) > 3;
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 3,
|
||||
name: 'Catedral',
|
||||
desc: 'Decay > 5s, mix 0.3-0.6 (no demasiado)',
|
||||
test: (mods) => {
|
||||
const rev = mods.find(m => m.type === 'reverb');
|
||||
if (!rev) return false;
|
||||
return (rev.params.decay ?? 2) > 5 &&
|
||||
(rev.params.mix ?? 0.5) >= 0.3 && (rev.params.mix ?? 0.5) <= 0.6;
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// ─────────────── LEVEL 5.4 ───────────────
|
||||
{
|
||||
id: 'w5-4',
|
||||
title: 'Distorsión',
|
||||
subtitle: 'Rompe la señal',
|
||||
description: 'La distorsión amplifica la señal hasta que se "rompe", creando armónicos nuevos. Desde el overdrive suave de un amplificador de guitarra hasta el fuzz salvaje — la distorsión añade agresividad y presencia.',
|
||||
concept: 'Conecta: Oscilador → Distortion → Output. Sube el "Drive" para más distorsión. Con una onda sine pura, escucharás cómo aparecen armónicos que no estaban antes.',
|
||||
availableModules: ['distortion'],
|
||||
preplacedModules: [
|
||||
{ id: 1, type: 'oscillator', x: 80, y: 80, params: { waveform: 'sine', frequency: 220, detune: 0 }, locked: true },
|
||||
{ id: 2, type: 'output', x: 600, y: 100, params: { volume: -10 }, locked: true },
|
||||
],
|
||||
target: {
|
||||
build: [
|
||||
{ type: 'oscillator', params: { waveform: 'sine', frequency: 220 } },
|
||||
],
|
||||
effects: [
|
||||
{ type: 'distortion', amount: 6 },
|
||||
],
|
||||
duration: 2.5,
|
||||
},
|
||||
checks: [
|
||||
{
|
||||
star: 1,
|
||||
name: 'Distorsión conectada',
|
||||
desc: 'Osc → Distortion → Salida',
|
||||
test: (mods, conns) => {
|
||||
const osc = mods.find(m => m.type === 'oscillator');
|
||||
const dist = mods.find(m => m.type === 'distortion');
|
||||
const out = mods.find(m => m.type === 'output');
|
||||
if (!osc || !dist || !out) return false;
|
||||
return conns.some(c => c.from.moduleId === osc.id && c.to.moduleId === dist.id) &&
|
||||
conns.some(c => c.from.moduleId === dist.id && c.to.moduleId === out.id);
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 2,
|
||||
name: 'Drive alto',
|
||||
desc: 'Distorsión con drive moderado-alto',
|
||||
test: (mods) => {
|
||||
const dist = mods.find(m => m.type === 'distortion');
|
||||
return dist && (dist.params.drive ?? 1) > 3;
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 3,
|
||||
name: 'Fuzz sine',
|
||||
desc: 'Onda sine con drive > 5 (máxima transformación)',
|
||||
test: (mods) => {
|
||||
const osc = mods.find(m => m.type === 'oscillator');
|
||||
const dist = mods.find(m => m.type === 'distortion');
|
||||
if (!osc || !dist) return false;
|
||||
return osc.params.waveform === 'sine' && (dist.params.drive ?? 1) > 5;
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// ─────────────── LEVEL 5.5 ───────────────
|
||||
{
|
||||
id: 'w5-5',
|
||||
title: 'Cadena de Efectos',
|
||||
subtitle: 'Orden importa',
|
||||
description: 'El orden de los efectos cambia radicalmente el resultado. Distorsión antes de delay suena diferente a delay antes de distorsión. Experimenta encadenando efectos en diferente orden.',
|
||||
concept: 'Prueba: Osc → Distortion → Delay → Output (la distorsión se repite limpia). El orden crea caracteres distintos. Encadena al menos 2 efectos diferentes.',
|
||||
availableModules: ['delay', 'distortion'],
|
||||
preplacedModules: [
|
||||
{ id: 1, type: 'oscillator', x: 60, y: 80, params: { waveform: 'sawtooth', frequency: 220, detune: 0 }, locked: true },
|
||||
{ id: 2, type: 'output', x: 740, y: 100, params: { volume: -10 }, locked: true },
|
||||
],
|
||||
target: {
|
||||
build: [
|
||||
{ type: 'oscillator', params: { waveform: 'sawtooth', frequency: 220 } },
|
||||
],
|
||||
effects: [
|
||||
{ type: 'distortion', amount: 3 },
|
||||
{ type: 'delay', time: 0.35, feedback: 0.35, wet: 0.5 },
|
||||
],
|
||||
duration: 3,
|
||||
},
|
||||
checks: [
|
||||
{
|
||||
star: 1,
|
||||
name: 'Dos efectos',
|
||||
desc: 'Al menos 2 módulos de efecto en la cadena',
|
||||
test: (mods, conns) => {
|
||||
const effects = mods.filter(m => ['delay', 'reverb', 'distortion'].includes(m.type));
|
||||
const out = mods.find(m => m.type === 'output');
|
||||
return effects.length >= 2 && out && conns.some(c => c.to.moduleId === out.id);
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 2,
|
||||
name: 'Efectos encadenados',
|
||||
desc: 'Los efectos están conectados en serie (uno al otro)',
|
||||
test: (mods, conns) => {
|
||||
const effects = mods.filter(m => ['delay', 'reverb', 'distortion'].includes(m.type));
|
||||
if (effects.length < 2) return false;
|
||||
// Check if any effect connects to another effect
|
||||
return effects.some(e1 => effects.some(e2 =>
|
||||
e1.id !== e2.id && conns.some(c => c.from.moduleId === e1.id && c.to.moduleId === e2.id)
|
||||
));
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 3,
|
||||
name: 'Cadena completa',
|
||||
desc: 'Osc → efecto1 → efecto2 → Output (cadena lineal)',
|
||||
test: (mods, conns) => {
|
||||
const osc = mods.find(m => m.type === 'oscillator');
|
||||
const effects = mods.filter(m => ['delay', 'reverb', 'distortion'].includes(m.type));
|
||||
const out = mods.find(m => m.type === 'output');
|
||||
if (!osc || effects.length < 2 || !out) return false;
|
||||
// Osc → some effect
|
||||
const oscToFx = effects.find(e => conns.some(c => c.from.moduleId === osc.id && c.to.moduleId === e.id));
|
||||
if (!oscToFx) return false;
|
||||
// That effect → another effect
|
||||
const fxToFx = effects.find(e => e.id !== oscToFx.id &&
|
||||
conns.some(c => c.from.moduleId === oscToFx.id && c.to.moduleId === e.id));
|
||||
if (!fxToFx) return false;
|
||||
// Second effect → output
|
||||
return conns.some(c => c.from.moduleId === fxToFx.id && c.to.moduleId === out.id);
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// ─────────────── LEVEL 5.6 ───────────────
|
||||
{
|
||||
id: 'w5-6',
|
||||
title: 'Delay + Filtro',
|
||||
subtitle: 'Dub echo',
|
||||
description: 'El sonido dub es delay con feedback alto pasado por un filtro que va quitando agudos. Cada repetición suena más oscura y lejana — es el efecto que definió el reggae dub en los 70.',
|
||||
concept: 'Osc → Delay (feedback alto ~0.5-0.7) → Filter (lowpass, cutoff bajo ~800 Hz) → Output. El filtro después del delay oscurece las repeticiones, creando profundidad.',
|
||||
availableModules: ['delay', 'filter'],
|
||||
preplacedModules: [
|
||||
{ id: 1, type: 'oscillator', x: 60, y: 80, params: { waveform: 'square', frequency: 330, detune: 0 }, locked: true },
|
||||
{ id: 2, type: 'output', x: 720, y: 100, params: { volume: -8 }, locked: true },
|
||||
],
|
||||
target: {
|
||||
build: [
|
||||
{ type: 'oscillator', params: { waveform: 'square', frequency: 330 } },
|
||||
],
|
||||
filter: { type: 'lowpass', frequency: 850, Q: 2 },
|
||||
effects: [
|
||||
{ type: 'delay', time: 0.4, feedback: 0.6, wet: 0.6 },
|
||||
],
|
||||
duration: 3,
|
||||
},
|
||||
checks: [
|
||||
{
|
||||
star: 1,
|
||||
name: 'Delay + Filter',
|
||||
desc: 'Osc → Delay → Filter → Output',
|
||||
test: (mods, conns) => {
|
||||
const osc = mods.find(m => m.type === 'oscillator');
|
||||
const del = mods.find(m => m.type === 'delay');
|
||||
const flt = mods.find(m => m.type === 'filter');
|
||||
const out = mods.find(m => m.type === 'output');
|
||||
if (!osc || !del || !flt || !out) return false;
|
||||
return conns.some(c => c.from.moduleId === osc.id && c.to.moduleId === del.id) &&
|
||||
conns.some(c => c.from.moduleId === del.id && c.to.moduleId === flt.id) &&
|
||||
conns.some(c => c.from.moduleId === flt.id && c.to.moduleId === out.id);
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 2,
|
||||
name: 'Echo largo',
|
||||
desc: 'Delay feedback > 0.4, time > 0.2s',
|
||||
test: (mods) => {
|
||||
const del = mods.find(m => m.type === 'delay');
|
||||
return del && (del.params.feedback ?? 0) > 0.4 && (del.params.time ?? 0.3) > 0.2;
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 3,
|
||||
name: 'Dub echo',
|
||||
desc: 'Feedback 0.5-0.7, filtro lowpass cutoff < 1000 Hz',
|
||||
test: (mods) => {
|
||||
const del = mods.find(m => m.type === 'delay');
|
||||
const flt = mods.find(m => m.type === 'filter');
|
||||
if (!del || !flt) return false;
|
||||
const fb = del.params.feedback ?? 0;
|
||||
return fb >= 0.5 && fb <= 0.7 &&
|
||||
flt.params.type === 'lowpass' && (flt.params.frequency ?? 2000) < 1000;
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// ─────────────── LEVEL 5.7 ───────────────
|
||||
{
|
||||
id: 'w5-7',
|
||||
title: 'Shoegaze Wall',
|
||||
subtitle: 'Reverb + Distorsión',
|
||||
description: 'El sonido shoegaze (My Bloody Valentine, Slowdive) es una pared de sonido creada con distorsión y reverb masiva. La distorsión aplasta la señal y la reverb la convierte en una nube etérea.',
|
||||
concept: 'Osc → Distortion (drive medio) → Reverb (decay largo, mix alto) → Output. La combinación de distorsión y reverb crea una textura densa y atmosférica.',
|
||||
availableModules: ['distortion', 'reverb'],
|
||||
preplacedModules: [
|
||||
{ id: 1, type: 'oscillator', x: 60, y: 80, params: { waveform: 'sawtooth', frequency: 220, detune: 0 }, locked: false },
|
||||
{ id: 2, type: 'output', x: 720, y: 100, params: { volume: -10 }, locked: true },
|
||||
],
|
||||
target: {
|
||||
build: [
|
||||
{ type: 'oscillator', params: { waveform: 'sawtooth', frequency: 220 } },
|
||||
],
|
||||
effects: [
|
||||
{ type: 'distortion', amount: 5 },
|
||||
{ type: 'reverb', decay: 6.5, wet: 0.65 },
|
||||
],
|
||||
duration: 4,
|
||||
},
|
||||
checks: [
|
||||
{
|
||||
star: 1,
|
||||
name: 'Dist + Reverb',
|
||||
desc: 'Osc → Distortion → Reverb → Output',
|
||||
test: (mods, conns) => {
|
||||
const osc = mods.find(m => m.type === 'oscillator');
|
||||
const dist = mods.find(m => m.type === 'distortion');
|
||||
const rev = mods.find(m => m.type === 'reverb');
|
||||
const out = mods.find(m => m.type === 'output');
|
||||
if (!osc || !dist || !rev || !out) return false;
|
||||
return conns.some(c => c.from.moduleId === osc.id && c.to.moduleId === dist.id) &&
|
||||
conns.some(c => c.from.moduleId === dist.id && c.to.moduleId === rev.id) &&
|
||||
conns.some(c => c.from.moduleId === rev.id && c.to.moduleId === out.id);
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 2,
|
||||
name: 'Pared de sonido',
|
||||
desc: 'Drive > 3, reverb decay > 4s',
|
||||
test: (mods) => {
|
||||
const dist = mods.find(m => m.type === 'distortion');
|
||||
const rev = mods.find(m => m.type === 'reverb');
|
||||
if (!dist || !rev) return false;
|
||||
return (dist.params.drive ?? 1) > 3 && (rev.params.decay ?? 2) > 4;
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 3,
|
||||
name: 'Shoegaze perfecto',
|
||||
desc: 'Drive 4-8, decay > 6s, reverb mix > 0.5',
|
||||
test: (mods) => {
|
||||
const dist = mods.find(m => m.type === 'distortion');
|
||||
const rev = mods.find(m => m.type === 'reverb');
|
||||
if (!dist || !rev) return false;
|
||||
const drive = dist.params.drive ?? 1;
|
||||
return drive >= 4 && drive <= 8 &&
|
||||
(rev.params.decay ?? 2) > 6 && (rev.params.mix ?? 0.5) > 0.5;
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// ─────────────── LEVEL 5.8: BOSS ───────────────
|
||||
{
|
||||
id: 'w5-8',
|
||||
title: 'Ambient Scape',
|
||||
subtitle: 'BOSS: Paisaje sonoro',
|
||||
description: 'Crea un paisaje sonoro ambient completo: un sonido que evoluciona lentamente, envuelto en efectos. Combina osciladores, filtros, modulación y efectos para crear una textura atmosférica.',
|
||||
concept: 'Osc → Filter (LFO al cutoff) → Delay → Reverb → Output. Envelope al VCA para control. Experimenta con tiempos largos, feedback alto, y modulación lenta para un sonido que "flota".',
|
||||
availableModules: ['oscillator', 'filter', 'vca', 'lfo', 'envelope', 'delay', 'reverb', 'keyboard'],
|
||||
preplacedModules: [
|
||||
{ id: 1, type: 'output', x: 900, y: 120, params: { volume: -8 }, locked: true },
|
||||
],
|
||||
target: {
|
||||
build: [
|
||||
{ type: 'oscillator', params: { waveform: 'sine', frequency: 110 } },
|
||||
],
|
||||
filter: { type: 'lowpass', frequency: 1200, Q: 3 },
|
||||
envelope: { attack: 0.5, decay: 0.3, sustain: 0.6, release: 1.5 },
|
||||
lfo: { frequency: 0.5, type: 'sine', min: 400, max: 4000, target: 'frequency' },
|
||||
effects: [
|
||||
{ type: 'delay', time: 0.5, feedback: 0.5, wet: 0.5 },
|
||||
{ type: 'reverb', decay: 5, wet: 0.6 },
|
||||
],
|
||||
duration: 5,
|
||||
},
|
||||
checks: [
|
||||
{
|
||||
star: 1,
|
||||
name: 'Cadena con efectos',
|
||||
desc: 'Al menos un efecto (delay/reverb) conectado a la salida',
|
||||
test: (mods, conns) => {
|
||||
const effects = mods.filter(m => ['delay', 'reverb'].includes(m.type));
|
||||
const out = mods.find(m => m.type === 'output');
|
||||
if (effects.length === 0 || !out) return false;
|
||||
return effects.some(e => conns.some(c => c.from.moduleId === e.id && c.to.moduleId === out.id)) ||
|
||||
conns.some(c => c.to.moduleId === out.id && effects.some(e => c.from.moduleId === e.id));
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 2,
|
||||
name: 'Modulación + Efectos',
|
||||
desc: 'Tiene oscilador, filtro, y al menos 2 efectos conectados',
|
||||
test: (mods, conns) => {
|
||||
const osc = mods.find(m => m.type === 'oscillator');
|
||||
const flt = mods.find(m => m.type === 'filter');
|
||||
const effects = mods.filter(m => ['delay', 'reverb', 'distortion'].includes(m.type));
|
||||
if (!osc || !flt || effects.length < 2) return false;
|
||||
// Check osc is connected to something
|
||||
return conns.some(c => c.from.moduleId === osc.id) && effects.length >= 2;
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 3,
|
||||
name: 'Paisaje completo',
|
||||
desc: 'Osc+Filter+LFO(cutoff)+Delay+Reverb, todo conectado',
|
||||
test: (mods, conns) => {
|
||||
const osc = mods.find(m => m.type === 'oscillator');
|
||||
const flt = mods.find(m => m.type === 'filter');
|
||||
const lfo = mods.find(m => m.type === 'lfo');
|
||||
const del = mods.find(m => m.type === 'delay');
|
||||
const rev = mods.find(m => m.type === 'reverb');
|
||||
const out = mods.find(m => m.type === 'output');
|
||||
if (!osc || !flt || !lfo || !del || !rev || !out) return false;
|
||||
return conns.some(c => c.from.moduleId === osc.id && c.to.moduleId === flt.id) &&
|
||||
conns.some(c => c.from.moduleId === lfo.id && c.to.moduleId === flt.id && c.to.port === 'cutoff') &&
|
||||
conns.some(c => c.to.moduleId === out.id);
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
554
packages/client/src/game/levels/world6.js
Normal file
554
packages/client/src/game/levels/world6.js
Normal file
@@ -0,0 +1,554 @@
|
||||
/**
|
||||
* World 6 — "Diseño Sonoro" (Sound Design Mastery)
|
||||
*
|
||||
* Teaches: putting it ALL together, real-world sound recreation
|
||||
* 8 levels, boss challenges combining everything learned
|
||||
*/
|
||||
|
||||
export const WORLD_6 = {
|
||||
id: 'w6',
|
||||
name: 'Diseño Sonoro',
|
||||
subtitle: 'Combina todo para crear sonidos reales',
|
||||
icon: '◉',
|
||||
color: '#ff44aa',
|
||||
unlockStars: 60,
|
||||
levels: [
|
||||
// ─────────────── LEVEL 6.1 ───────────────
|
||||
{
|
||||
id: 'w6-1',
|
||||
title: 'Kick Drum',
|
||||
subtitle: 'El latido del beat',
|
||||
description: 'Un kick sintético se crea con un oscilador sine a frecuencia baja + un envelope muy rápido en el VCA para el golpe. Algunos añaden un pitch envelope para el "click" del ataque.',
|
||||
concept: 'Osc sine a ~55 Hz → VCA → Output. Envelope con attack 0, decay ~0.2s, sustain 0. El envelope al VCA crea el golpe. Para el click: un segundo osc más agudo con decay ultra-corto.',
|
||||
availableModules: ['oscillator', 'vca', 'envelope', 'keyboard', 'mixer'],
|
||||
preplacedModules: [
|
||||
{ id: 1, type: 'output', x: 700, y: 120, params: { volume: -6 }, locked: true },
|
||||
],
|
||||
target: {
|
||||
build: [
|
||||
{ type: 'oscillator', params: { waveform: 'sine', frequency: 55 } },
|
||||
],
|
||||
envelope: { attack: 0, decay: 0.25, sustain: 0, release: 0.1 },
|
||||
duration: 2,
|
||||
},
|
||||
checks: [
|
||||
{
|
||||
star: 1,
|
||||
name: 'Kick básico',
|
||||
desc: 'Osc sine grave + VCA + Envelope → Output',
|
||||
test: (mods, conns) => {
|
||||
const osc = mods.find(m => m.type === 'oscillator');
|
||||
const vca = mods.find(m => m.type === 'vca');
|
||||
const env = mods.find(m => m.type === 'envelope');
|
||||
const out = mods.find(m => m.type === 'output');
|
||||
if (!osc || !vca || !env || !out) return false;
|
||||
return (osc.params.frequency ?? 440) < 100 &&
|
||||
conns.some(c => c.from.moduleId === env.id && c.to.moduleId === vca.id && c.to.port === 'cv');
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 2,
|
||||
name: 'Punch',
|
||||
desc: 'Sine < 80 Hz, envelope rápido (attack < 0.01, decay < 0.3)',
|
||||
test: (mods) => {
|
||||
const osc = mods.find(m => m.type === 'oscillator');
|
||||
const env = mods.find(m => m.type === 'envelope');
|
||||
if (!osc || !env) return false;
|
||||
return (osc.params.frequency ?? 440) < 80 &&
|
||||
osc.params.waveform === 'sine' &&
|
||||
(env.params.attack ?? 0.01) < 0.01 &&
|
||||
(env.params.decay ?? 0.2) < 0.3 &&
|
||||
(env.params.sustain ?? 0.5) < 0.1;
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 3,
|
||||
name: '808 Kick',
|
||||
desc: 'Frecuencia 40-60 Hz, decay 0.15-0.4s, keyboard conectado',
|
||||
test: (mods, conns) => {
|
||||
const osc = mods.find(m => m.type === 'oscillator');
|
||||
const env = mods.find(m => m.type === 'envelope');
|
||||
const kb = mods.find(m => m.type === 'keyboard');
|
||||
if (!osc || !env || !kb) return false;
|
||||
const freq = osc.params.frequency ?? 440;
|
||||
const decay = env.params.decay ?? 0.2;
|
||||
return freq >= 40 && freq <= 60 && decay >= 0.15 && decay <= 0.4 &&
|
||||
conns.some(c => c.from.moduleId === kb.id && c.to.moduleId === env.id && c.to.port === 'gate');
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// ─────────────── LEVEL 6.2 ───────────────
|
||||
{
|
||||
id: 'w6-2',
|
||||
title: 'Hi-Hat',
|
||||
subtitle: 'Noise + Filtro + Envelope',
|
||||
description: 'Los hi-hats son ruido blanco filtrado con un envelope corto. El ruido proporciona la textura metálica, el filtro highpass quita los graves, y el envelope corto le da el "tss".',
|
||||
concept: 'Noise → Filter (highpass, cutoff alto ~6000+ Hz) → VCA → Output. Envelope corto (attack 0, decay ~0.05-0.15s, sustain 0) al VCA. Keyboard al gate del envelope.',
|
||||
availableModules: ['noise', 'filter', 'vca', 'envelope', 'keyboard'],
|
||||
preplacedModules: [
|
||||
{ id: 1, type: 'output', x: 700, y: 120, params: { volume: -10 }, locked: true },
|
||||
],
|
||||
target: {
|
||||
build: [
|
||||
{ type: 'noise', params: { type: 'white' } },
|
||||
],
|
||||
filter: { type: 'highpass', frequency: 7000, Q: 2 },
|
||||
envelope: { attack: 0, decay: 0.08, sustain: 0, release: 0 },
|
||||
duration: 2,
|
||||
},
|
||||
checks: [
|
||||
{
|
||||
star: 1,
|
||||
name: 'Ruido filtrado',
|
||||
desc: 'Noise → Filter → VCA → Output con envelope',
|
||||
test: (mods, conns) => {
|
||||
const noise = mods.find(m => m.type === 'noise');
|
||||
const flt = mods.find(m => m.type === 'filter');
|
||||
const vca = mods.find(m => m.type === 'vca');
|
||||
const env = mods.find(m => m.type === 'envelope');
|
||||
if (!noise || !flt || !vca || !env) return false;
|
||||
return conns.some(c => c.from.moduleId === noise.id && c.to.moduleId === flt.id) &&
|
||||
conns.some(c => c.from.moduleId === env.id && c.to.moduleId === vca.id && c.to.port === 'cv');
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 2,
|
||||
name: 'Sonido metálico',
|
||||
desc: 'Filtro highpass, cutoff > 4000 Hz',
|
||||
test: (mods) => {
|
||||
const flt = mods.find(m => m.type === 'filter');
|
||||
return flt && flt.params.type === 'highpass' && (flt.params.frequency ?? 1000) > 4000;
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 3,
|
||||
name: 'Hi-hat cerrado',
|
||||
desc: 'HP > 6000 Hz, envelope ultra-corto (decay < 0.1s)',
|
||||
test: (mods) => {
|
||||
const flt = mods.find(m => m.type === 'filter');
|
||||
const env = mods.find(m => m.type === 'envelope');
|
||||
if (!flt || !env) return false;
|
||||
return flt.params.type === 'highpass' && (flt.params.frequency ?? 1000) > 6000 &&
|
||||
(env.params.decay ?? 0.2) < 0.1 && (env.params.sustain ?? 0.5) < 0.05;
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// ─────────────── LEVEL 6.3 ───────────────
|
||||
{
|
||||
id: 'w6-3',
|
||||
title: 'Snare',
|
||||
subtitle: 'Tono + Ruido',
|
||||
description: 'Un snare es la combinación de un cuerpo tonal (oscilador) y una cola de ruido (noise). Se mezclan juntos con envelopes diferentes — el tono muere rápido y el ruido un poco después.',
|
||||
concept: 'Dos cadenas: 1) Osc sine (~200 Hz) → VCA1 → Mixer. 2) Noise → Filter HP → VCA2 → Mixer. Mixer → Output. Envelopes diferentes: el tono más corto que el ruido.',
|
||||
availableModules: ['oscillator', 'noise', 'filter', 'vca', 'envelope', 'keyboard', 'mixer'],
|
||||
preplacedModules: [
|
||||
{ id: 1, type: 'output', x: 800, y: 140, params: { volume: -8 }, locked: true },
|
||||
],
|
||||
target: {
|
||||
build: [
|
||||
{ type: 'oscillator', params: { waveform: 'sine', frequency: 200 } },
|
||||
{ type: 'noise', params: { type: 'white' } },
|
||||
],
|
||||
filter: { type: 'highpass', frequency: 3000, Q: 1.5 },
|
||||
envelope: { attack: 0, decay: 0.12, sustain: 0, release: 0.05 },
|
||||
duration: 2,
|
||||
},
|
||||
checks: [
|
||||
{
|
||||
star: 1,
|
||||
name: 'Dos fuentes',
|
||||
desc: 'Oscilador Y Noise, ambos al mixer → output',
|
||||
test: (mods, conns) => {
|
||||
const osc = mods.find(m => m.type === 'oscillator');
|
||||
const noise = mods.find(m => m.type === 'noise');
|
||||
const mixer = mods.find(m => m.type === 'mixer');
|
||||
const out = mods.find(m => m.type === 'output');
|
||||
return osc && noise && mixer && out &&
|
||||
conns.some(c => c.to.moduleId === out.id);
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 2,
|
||||
name: 'Envelopes',
|
||||
desc: 'Al menos 2 envelopes controlando VCAs',
|
||||
test: (mods, conns) => {
|
||||
const envs = mods.filter(m => m.type === 'envelope');
|
||||
const vcas = mods.filter(m => m.type === 'vca');
|
||||
if (envs.length < 2 || vcas.length < 2) return false;
|
||||
const envToVca = envs.filter(e =>
|
||||
vcas.some(v => conns.some(c => c.from.moduleId === e.id && c.to.moduleId === v.id && c.to.port === 'cv'))
|
||||
);
|
||||
return envToVca.length >= 2;
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 3,
|
||||
name: 'Snare realista',
|
||||
desc: 'Osc ~150-250 Hz, noise filtrado HP, decays diferentes',
|
||||
test: (mods) => {
|
||||
const osc = mods.find(m => m.type === 'oscillator');
|
||||
const flt = mods.find(m => m.type === 'filter');
|
||||
const envs = mods.filter(m => m.type === 'envelope');
|
||||
if (!osc || !flt || envs.length < 2) return false;
|
||||
const freq = osc.params.frequency ?? 440;
|
||||
const decays = envs.map(e => e.params.decay ?? 0.2);
|
||||
return freq >= 150 && freq <= 250 &&
|
||||
flt.params.type === 'highpass' &&
|
||||
Math.abs(decays[0] - decays[1]) > 0.03;
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// ─────────────── LEVEL 6.4 ───────────────
|
||||
{
|
||||
id: 'w6-4',
|
||||
title: 'Pad Espacial',
|
||||
subtitle: 'Capas + Efectos',
|
||||
description: 'Un pad espacial combina múltiples osciladores detuned, un filtro suave, un envelope lento, y efectos de reverb/delay para crear una textura inmersiva que rellena todo el espectro.',
|
||||
concept: 'Dos oscs saw detuned → Mixer → Filter LP → VCA → Reverb → Output. Envelope lento al VCA. LFO lento al cutoff. Reverb con decay largo. El resultado: un colchón de sonido etéreo.',
|
||||
availableModules: ['oscillator', 'filter', 'vca', 'lfo', 'envelope', 'mixer', 'reverb', 'keyboard'],
|
||||
preplacedModules: [
|
||||
{ id: 1, type: 'output', x: 900, y: 140, params: { volume: -8 }, locked: true },
|
||||
],
|
||||
target: {
|
||||
build: [
|
||||
{ type: 'oscillator', params: { waveform: 'sawtooth', frequency: 110, detune: -8 } },
|
||||
{ type: 'oscillator', params: { waveform: 'sawtooth', frequency: 110, detune: 8 } },
|
||||
],
|
||||
filter: { type: 'lowpass', frequency: 1500, Q: 3 },
|
||||
envelope: { attack: 1, decay: 0.4, sustain: 0.7, release: 2 },
|
||||
lfo: { frequency: 0.6, type: 'sine', min: 600, max: 3500, target: 'frequency' },
|
||||
effects: [
|
||||
{ type: 'reverb', decay: 5.5, wet: 0.6 },
|
||||
],
|
||||
duration: 5,
|
||||
},
|
||||
checks: [
|
||||
{
|
||||
star: 1,
|
||||
name: 'Múltiples osciladores',
|
||||
desc: 'Al menos 2 osciladores mezclados',
|
||||
test: (mods, conns) => {
|
||||
const oscs = mods.filter(m => m.type === 'oscillator');
|
||||
const mixer = mods.find(m => m.type === 'mixer');
|
||||
return oscs.length >= 2 && mixer;
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 2,
|
||||
name: 'Con efectos',
|
||||
desc: 'Reverb en la cadena con decay > 3s',
|
||||
test: (mods) => {
|
||||
const rev = mods.find(m => m.type === 'reverb');
|
||||
return rev && (rev.params.decay ?? 2) > 3;
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 3,
|
||||
name: 'Pad completo',
|
||||
desc: '2+ oscs detuned, filtro, LFO al cutoff, envelope al VCA, reverb',
|
||||
test: (mods, conns) => {
|
||||
const oscs = mods.filter(m => m.type === 'oscillator');
|
||||
const flt = mods.find(m => m.type === 'filter');
|
||||
const lfo = mods.find(m => m.type === 'lfo');
|
||||
const env = mods.find(m => m.type === 'envelope');
|
||||
const vca = mods.find(m => m.type === 'vca');
|
||||
const rev = mods.find(m => m.type === 'reverb');
|
||||
if (oscs.length < 2 || !flt || !lfo || !env || !vca || !rev) return false;
|
||||
// Check detune
|
||||
const hasDetune = oscs.some(o => Math.abs(o.params.detune ?? 0) > 2);
|
||||
// Check LFO to cutoff
|
||||
const lfoToCutoff = conns.some(c => c.from.moduleId === lfo.id && c.to.moduleId === flt.id && c.to.port === 'cutoff');
|
||||
// Check env to VCA
|
||||
const envToVca = conns.some(c => c.from.moduleId === env.id && c.to.moduleId === vca.id && c.to.port === 'cv');
|
||||
return hasDetune && lfoToCutoff && envToVca;
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// ─────────────── LEVEL 6.5 ───────────────
|
||||
{
|
||||
id: 'w6-5',
|
||||
title: 'Bajo Reese',
|
||||
subtitle: 'El bajo de Drum & Bass',
|
||||
description: 'El Reese bass es un bajo icónico del Drum & Bass: dos osciladores sawtooth detuned a frecuencia grave, pasados por un filtro lowpass que se abre y cierra. Es gordo, agresivo y hipnótico.',
|
||||
concept: 'Dos oscs sawtooth a ~55 Hz, uno con detune +7-12. Mixer → Filter LP resonante → VCA → Output. LFO lento (~0.3-1 Hz) al cutoff del filtro. El "movimiento" del filtro es lo que le da vida.',
|
||||
availableModules: ['oscillator', 'filter', 'vca', 'lfo', 'mixer', 'envelope', 'keyboard'],
|
||||
preplacedModules: [
|
||||
{ id: 1, type: 'output', x: 800, y: 120, params: { volume: -8 }, locked: true },
|
||||
],
|
||||
target: {
|
||||
build: [
|
||||
{ type: 'oscillator', params: { waveform: 'sawtooth', frequency: 55, detune: -9 } },
|
||||
{ type: 'oscillator', params: { waveform: 'sawtooth', frequency: 55, detune: 9 } },
|
||||
],
|
||||
filter: { type: 'lowpass', frequency: 400, Q: 8 },
|
||||
lfo: { frequency: 0.7, type: 'sine', min: 200, max: 2000, target: 'frequency' },
|
||||
envelope: { attack: 0.05, decay: 0.2, sustain: 0.6, release: 0.3 },
|
||||
duration: 4,
|
||||
},
|
||||
checks: [
|
||||
{
|
||||
star: 1,
|
||||
name: 'Dos sierras graves',
|
||||
desc: '2 osciladores saw < 100 Hz mezclados',
|
||||
test: (mods) => {
|
||||
const oscs = mods.filter(m => m.type === 'oscillator');
|
||||
if (oscs.length < 2) return false;
|
||||
return oscs.filter(o => o.params.waveform === 'sawtooth' && (o.params.frequency ?? 440) < 100).length >= 2;
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 2,
|
||||
name: 'Detune + Filtro',
|
||||
desc: 'Osciladores detuned, filtro LP en la cadena',
|
||||
test: (mods) => {
|
||||
const oscs = mods.filter(m => m.type === 'oscillator' && m.params.waveform === 'sawtooth');
|
||||
const flt = mods.find(m => m.type === 'filter');
|
||||
if (oscs.length < 2 || !flt) return false;
|
||||
const hasDetune = oscs.some(o => Math.abs(o.params.detune ?? 0) > 3);
|
||||
return hasDetune && flt.params.type === 'lowpass';
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 3,
|
||||
name: 'Reese Bass',
|
||||
desc: 'Detuned saws + LP resonante + LFO al cutoff',
|
||||
test: (mods, conns) => {
|
||||
const oscs = mods.filter(m => m.type === 'oscillator' && m.params.waveform === 'sawtooth');
|
||||
const flt = mods.find(m => m.type === 'filter');
|
||||
const lfo = mods.find(m => m.type === 'lfo');
|
||||
if (oscs.length < 2 || !flt || !lfo) return false;
|
||||
const hasDetune = oscs.some(o => Math.abs(o.params.detune ?? 0) > 3);
|
||||
const isLPres = flt.params.type === 'lowpass' && (flt.params.Q ?? 1) > 3;
|
||||
const lfoToCut = conns.some(c => c.from.moduleId === lfo.id && c.to.moduleId === flt.id && c.to.port === 'cutoff');
|
||||
return hasDetune && isLPres && lfoToCut;
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// ─────────────── LEVEL 6.6 ───────────────
|
||||
{
|
||||
id: 'w6-6',
|
||||
title: 'Efecto Laser',
|
||||
subtitle: 'Pew pew!',
|
||||
description: 'El sonido laser clásico de los juegos retro es un oscilador cuya frecuencia baja rápidamente — un pitch sweep descendente. Se consigue con un envelope que modula la frecuencia del oscilador.',
|
||||
concept: 'Osc square/saw → VCA → Output. Envelope al VCA (ataque rápido, decay corto). Un SEGUNDO envelope a la frecuencia del osc (empieza agudo y baja rápido). Keyboard dispara ambos.',
|
||||
availableModules: ['oscillator', 'vca', 'envelope', 'keyboard'],
|
||||
preplacedModules: [
|
||||
{ id: 1, type: 'output', x: 700, y: 120, params: { volume: -8 }, locked: true },
|
||||
],
|
||||
target: {
|
||||
build: [
|
||||
{ type: 'oscillator', params: { waveform: 'square', frequency: 440 } },
|
||||
],
|
||||
envelope: { attack: 0.01, decay: 0.15, sustain: 0.05, release: 0.1 },
|
||||
duration: 2,
|
||||
},
|
||||
checks: [
|
||||
{
|
||||
star: 1,
|
||||
name: 'Sonido con envelope',
|
||||
desc: 'Osc → VCA → Output con envelope y keyboard',
|
||||
test: (mods, conns) => {
|
||||
const osc = mods.find(m => m.type === 'oscillator');
|
||||
const vca = mods.find(m => m.type === 'vca');
|
||||
const env = mods.find(m => m.type === 'envelope');
|
||||
const kb = mods.find(m => m.type === 'keyboard');
|
||||
const out = mods.find(m => m.type === 'output');
|
||||
return osc && vca && env && kb && out &&
|
||||
conns.some(c => c.from.moduleId === env.id && c.to.moduleId === vca.id && c.to.port === 'cv');
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 2,
|
||||
name: 'Pitch envelope',
|
||||
desc: 'Un envelope conectado a la frecuencia del oscilador',
|
||||
test: (mods, conns) => {
|
||||
const envs = mods.filter(m => m.type === 'envelope');
|
||||
const osc = mods.find(m => m.type === 'oscillator');
|
||||
if (!osc || envs.length < 2) return false;
|
||||
return envs.some(e => conns.some(c =>
|
||||
c.from.moduleId === e.id && c.to.moduleId === osc.id && c.to.port === 'freq'
|
||||
));
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 3,
|
||||
name: 'Pew pew!',
|
||||
desc: 'Osc square/saw, pitch env corto (decay < 0.2s), keyboard a ambos gates',
|
||||
test: (mods, conns) => {
|
||||
const osc = mods.find(m => m.type === 'oscillator');
|
||||
const envs = mods.filter(m => m.type === 'envelope');
|
||||
const kb = mods.find(m => m.type === 'keyboard');
|
||||
if (!osc || envs.length < 2 || !kb) return false;
|
||||
const wave = osc.params.waveform;
|
||||
const pitchEnv = envs.find(e => conns.some(c =>
|
||||
c.from.moduleId === e.id && c.to.moduleId === osc.id && c.to.port === 'freq'
|
||||
));
|
||||
if (!pitchEnv) return false;
|
||||
const gated = envs.filter(e =>
|
||||
conns.some(c => c.from.moduleId === kb.id && c.to.moduleId === e.id && c.to.port === 'gate')
|
||||
);
|
||||
return (wave === 'square' || wave === 'sawtooth') &&
|
||||
(pitchEnv.params.decay ?? 0.2) < 0.2 &&
|
||||
gated.length >= 2;
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// ─────────────── LEVEL 6.7 ───────────────
|
||||
{
|
||||
id: 'w6-7',
|
||||
title: 'Arpegio Trance',
|
||||
subtitle: 'Secuenciador + Synth',
|
||||
description: 'Los arpegios de trance son notas rápidas que crean patrones hipnóticos. Usa el secuenciador para disparar notas en el sintetizador con un envelope corto y un filtro que sube y baja.',
|
||||
concept: 'Sequencer → Osc freq + Envelope gate. Osc → Filter → VCA → Delay → Output. Envelope corto al VCA (pluck). LFO lento al cutoff del filtro. El delay repite el patrón.',
|
||||
availableModules: ['oscillator', 'filter', 'vca', 'lfo', 'envelope', 'delay', 'sequencer'],
|
||||
preplacedModules: [
|
||||
{ id: 1, type: 'output', x: 900, y: 140, params: { volume: -8 }, locked: true },
|
||||
],
|
||||
target: {
|
||||
build: [
|
||||
{ type: 'oscillator', params: { waveform: 'sawtooth', frequency: 330 } },
|
||||
],
|
||||
filter: { type: 'lowpass', frequency: 2000, Q: 4 },
|
||||
envelope: { attack: 0.005, decay: 0.15, sustain: 0.1, release: 0.08 },
|
||||
lfo: { frequency: 1.5, type: 'sine', min: 1000, max: 4000, target: 'frequency' },
|
||||
effects: [
|
||||
{ type: 'delay', time: 0.25, feedback: 0.35, wet: 0.45 },
|
||||
],
|
||||
triggerPattern: { interval: 0.25, count: 16 },
|
||||
duration: 4,
|
||||
},
|
||||
checks: [
|
||||
{
|
||||
star: 1,
|
||||
name: 'Secuenciador activo',
|
||||
desc: 'Sequencer conectado al oscilador',
|
||||
test: (mods, conns) => {
|
||||
const seq = mods.find(m => m.type === 'sequencer');
|
||||
const osc = mods.find(m => m.type === 'oscillator');
|
||||
if (!seq || !osc) return false;
|
||||
return conns.some(c => c.from.moduleId === seq.id && c.to.moduleId === osc.id);
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 2,
|
||||
name: 'Synth con envolvente',
|
||||
desc: 'Osc → Filter → VCA → Output con envelope al VCA',
|
||||
test: (mods, conns) => {
|
||||
const osc = mods.find(m => m.type === 'oscillator');
|
||||
const flt = mods.find(m => m.type === 'filter');
|
||||
const vca = mods.find(m => m.type === 'vca');
|
||||
const env = mods.find(m => m.type === 'envelope');
|
||||
const out = mods.find(m => m.type === 'output');
|
||||
if (!osc || !flt || !vca || !env || !out) return false;
|
||||
return conns.some(c => c.from.moduleId === env.id && c.to.moduleId === vca.id && c.to.port === 'cv');
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 3,
|
||||
name: 'Trance completo',
|
||||
desc: 'Sequencer + synth sustractivo completo + delay',
|
||||
test: (mods, conns) => {
|
||||
const seq = mods.find(m => m.type === 'sequencer');
|
||||
const osc = mods.find(m => m.type === 'oscillator');
|
||||
const flt = mods.find(m => m.type === 'filter');
|
||||
const vca = mods.find(m => m.type === 'vca');
|
||||
const del = mods.find(m => m.type === 'delay');
|
||||
const env = mods.find(m => m.type === 'envelope');
|
||||
if (!seq || !osc || !flt || !vca || !del || !env) return false;
|
||||
return conns.some(c => c.from.moduleId === seq.id && c.to.moduleId === osc.id) &&
|
||||
conns.some(c => c.from.moduleId === env.id && c.to.moduleId === vca.id && c.to.port === 'cv');
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// ─────────────── LEVEL 6.8: BOSS ───────────────
|
||||
{
|
||||
id: 'w6-8',
|
||||
title: 'Tu Sintetizador',
|
||||
subtitle: 'BOSS FINAL: Diseña tu propio sonido',
|
||||
description: 'Has aprendido osciladores, filtros, envelopes, modulación y efectos. Ahora construye el sintetizador más completo que puedas. Sin restricciones. Sin guía. Solo tu creatividad y todo lo que has aprendido.',
|
||||
concept: 'Construye un patch completo con al menos: 2 osciladores, 1 filtro, 1 VCA, 2 envelopes, 1 LFO, 1 efecto, y un keyboard. ¡Hazlo sonar increíble!',
|
||||
availableModules: ['oscillator', 'filter', 'vca', 'lfo', 'envelope', 'mixer', 'noise', 'keyboard', 'delay', 'reverb', 'distortion', 'sequencer'],
|
||||
preplacedModules: [
|
||||
{ id: 1, type: 'output', x: 950, y: 160, params: { volume: -8 }, locked: true },
|
||||
],
|
||||
target: {
|
||||
build: [
|
||||
{ type: 'oscillator', params: { waveform: 'sawtooth', frequency: 165, detune: -6 } },
|
||||
{ type: 'oscillator', params: { waveform: 'square', frequency: 165, detune: 6 } },
|
||||
{ type: 'noise', params: { type: 'white' } },
|
||||
],
|
||||
filter: { type: 'lowpass', frequency: 1800, Q: 6 },
|
||||
envelope: { attack: 0.2, decay: 0.4, sustain: 0.5, release: 0.8 },
|
||||
lfo: { frequency: 2, type: 'sine', min: 0.3, max: 1.2, target: 'amplitude' },
|
||||
effects: [
|
||||
{ type: 'distortion', amount: 2 },
|
||||
{ type: 'delay', time: 0.3, feedback: 0.4, wet: 0.4 },
|
||||
{ type: 'reverb', decay: 3.5, wet: 0.45 },
|
||||
],
|
||||
duration: 5,
|
||||
},
|
||||
checks: [
|
||||
{
|
||||
star: 1,
|
||||
name: 'Patch funcional',
|
||||
desc: 'Al menos 5 módulos conectados con sonido a la salida',
|
||||
test: (mods, conns) => {
|
||||
const out = mods.find(m => m.type === 'output');
|
||||
if (!out) return false;
|
||||
// Count non-output modules
|
||||
const modCount = mods.filter(m => m.type !== 'output').length;
|
||||
// Something reaches output
|
||||
const hasOutput = conns.some(c => c.to.moduleId === out.id);
|
||||
return modCount >= 5 && hasOutput && conns.length >= 5;
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 2,
|
||||
name: 'Síntesis completa',
|
||||
desc: 'Tiene osc + filtro + VCA + envelope + efecto, todos conectados',
|
||||
test: (mods, conns) => {
|
||||
const osc = mods.find(m => m.type === 'oscillator');
|
||||
const flt = mods.find(m => m.type === 'filter');
|
||||
const vca = mods.find(m => m.type === 'vca');
|
||||
const env = mods.find(m => m.type === 'envelope');
|
||||
const effects = mods.filter(m => ['delay', 'reverb', 'distortion'].includes(m.type));
|
||||
if (!osc || !flt || !vca || !env || effects.length === 0) return false;
|
||||
// All main pieces should have connections
|
||||
const oscConn = conns.some(c => c.from.moduleId === osc.id);
|
||||
const envConn = conns.some(c => c.from.moduleId === env.id);
|
||||
return oscConn && envConn && conns.length >= 7;
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 3,
|
||||
name: 'Maestro del Sonido',
|
||||
desc: '8+ módulos, 2+ oscs, 2+ envelopes, LFO, efecto, keyboard — ¡todo!',
|
||||
test: (mods, conns) => {
|
||||
const oscs = mods.filter(m => m.type === 'oscillator');
|
||||
const envs = mods.filter(m => m.type === 'envelope');
|
||||
const lfo = mods.find(m => m.type === 'lfo');
|
||||
const kb = mods.find(m => m.type === 'keyboard');
|
||||
const effects = mods.filter(m => ['delay', 'reverb', 'distortion'].includes(m.type));
|
||||
const nonOutput = mods.filter(m => m.type !== 'output');
|
||||
return nonOutput.length >= 8 && oscs.length >= 2 && envs.length >= 2 &&
|
||||
lfo && kb && effects.length >= 1 && conns.length >= 10;
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
547
packages/client/src/game/levels/world7.js
Normal file
547
packages/client/src/game/levels/world7.js
Normal file
@@ -0,0 +1,547 @@
|
||||
/**
|
||||
* World 7 — "Secuencias y Ritmos" (Sequences and Rhythms)
|
||||
*
|
||||
* Teaches: sequencer basics, bass sequences, pluck sequences, filtered sequences,
|
||||
* basic drum machines, swing, effects on sequences
|
||||
* 8 levels + boss challenge: "Beat Completo" (Full beat with bass + drums + effects)
|
||||
*/
|
||||
|
||||
export const WORLD_7 = {
|
||||
id: 'w7',
|
||||
name: 'Secuencias y Ritmos',
|
||||
subtitle: 'Programando patrones',
|
||||
icon: '▦',
|
||||
color: '#ff8800',
|
||||
unlockStars: 72,
|
||||
levels: [
|
||||
// ─────────────── LEVEL 7.1 ───────────────
|
||||
{
|
||||
id: 'w7-1',
|
||||
title: 'Primer Secuenciador',
|
||||
subtitle: 'Notas en secuencia',
|
||||
description: 'El secuenciador es como un metrónomo que dispara notas en un patrón. Cada paso tiene una nota. Conéctalo a un oscilador y tendrás una melodía que se repite.',
|
||||
concept: 'Sequencer → Osc freq. Osc → VCA → Output. Envelope dispara el VCA. El resultado: una melodía secuenciada.',
|
||||
availableModules: ['oscillator', 'vca', 'envelope', 'sequencer'],
|
||||
preplacedModules: [
|
||||
{ id: 1, type: 'output', x: 700, y: 120, params: { volume: -6 }, locked: true },
|
||||
],
|
||||
target: {
|
||||
build: [
|
||||
{ type: 'oscillator', params: { waveform: 'sine', frequency: 330 } },
|
||||
],
|
||||
envelope: { attack: 0.01, decay: 0.12, sustain: 0, release: 0.05 },
|
||||
triggerPattern: { interval: 0.5, count: 4 },
|
||||
duration: 2,
|
||||
},
|
||||
checks: [
|
||||
{
|
||||
star: 1,
|
||||
name: 'Secuenciador conectado',
|
||||
desc: 'Sequencer → Osc → VCA → Output',
|
||||
test: (mods, conns) => {
|
||||
const seq = mods.find(m => m.type === 'sequencer');
|
||||
const osc = mods.find(m => m.type === 'oscillator');
|
||||
const vca = mods.find(m => m.type === 'vca');
|
||||
const out = mods.find(m => m.type === 'output');
|
||||
if (!seq || !osc || !vca || !out) return false;
|
||||
return conns.some(c => c.from.moduleId === seq.id && c.to.moduleId === osc.id);
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 2,
|
||||
name: 'Sonido rítmico',
|
||||
desc: 'Envelope dispara el VCA en cadencia',
|
||||
test: (mods, conns) => {
|
||||
const seq = mods.find(m => m.type === 'sequencer');
|
||||
const env = mods.find(m => m.type === 'envelope');
|
||||
const vca = mods.find(m => m.type === 'vca');
|
||||
if (!seq || !env || !vca) return false;
|
||||
return conns.some(c => c.from.moduleId === seq.id && c.to.moduleId === env.id && c.to.port === 'gate') &&
|
||||
conns.some(c => c.from.moduleId === env.id && c.to.moduleId === vca.id && c.to.port === 'cv');
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 3,
|
||||
name: 'Secuencia limpia',
|
||||
desc: 'Sequencer con BPM 140, oscilador sine, envelope corto (decay < 0.2s)',
|
||||
test: (mods) => {
|
||||
const seq = mods.find(m => m.type === 'sequencer');
|
||||
const osc = mods.find(m => m.type === 'oscillator');
|
||||
const env = mods.find(m => m.type === 'envelope');
|
||||
if (!seq || !osc || !env) return false;
|
||||
return (seq.params.bpm ?? 140) === 140 &&
|
||||
osc.params.waveform === 'sine' &&
|
||||
(env.params.decay ?? 0.2) < 0.2;
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// ─────────────── LEVEL 7.2 ───────────────
|
||||
{
|
||||
id: 'w7-2',
|
||||
title: 'Bajo Secuenciado',
|
||||
subtitle: 'Riffs graves y hipnóticos',
|
||||
description: 'Un riff de bajo es una frase corta repetida. Usa el secuenciador con un oscilador grave para crear un riff clásico — sawtooth detuned, filtro animado, sonido gordo.',
|
||||
concept: 'Secuenciador → Dos oscs saw (~55 Hz) detuned → Filter LP → VCA → Output. Envelope al VCA. LFO lento al cutoff. Hipnótico y gordo.',
|
||||
availableModules: ['oscillator', 'filter', 'vca', 'lfo', 'envelope', 'sequencer', 'mixer'],
|
||||
preplacedModules: [
|
||||
{ id: 1, type: 'output', x: 800, y: 120, params: { volume: -8 }, locked: true },
|
||||
],
|
||||
target: {
|
||||
build: [
|
||||
{ type: 'oscillator', params: { waveform: 'sawtooth', frequency: 55, detune: -8 } },
|
||||
{ type: 'oscillator', params: { waveform: 'sawtooth', frequency: 55, detune: 8 } },
|
||||
],
|
||||
filter: { type: 'lowpass', frequency: 600, Q: 5 },
|
||||
envelope: { attack: 0.02, decay: 0.15, sustain: 0.3, release: 0.1 },
|
||||
lfo: { frequency: 1, type: 'sine', min: 300, max: 1500, target: 'frequency' },
|
||||
triggerPattern: { interval: 1, count: 3 },
|
||||
duration: 3,
|
||||
},
|
||||
checks: [
|
||||
{
|
||||
star: 1,
|
||||
name: 'Bajo grave',
|
||||
desc: 'Sequencer a oscilador < 100 Hz',
|
||||
test: (mods, conns) => {
|
||||
const seq = mods.find(m => m.type === 'sequencer');
|
||||
const oscs = mods.filter(m => m.type === 'oscillator');
|
||||
if (!seq || oscs.length === 0) return false;
|
||||
return oscs.some(o => (o.params.frequency ?? 440) < 100) &&
|
||||
conns.some(c => c.from.moduleId === seq.id && c.to.moduleId === oscs[0].id);
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 2,
|
||||
name: 'Detuned y filtrado',
|
||||
desc: '2 oscs sawtooth detuned, filtro lowpass',
|
||||
test: (mods) => {
|
||||
const oscs = mods.filter(m => m.type === 'oscillator' && m.params.waveform === 'sawtooth');
|
||||
const flt = mods.find(m => m.type === 'filter');
|
||||
if (oscs.length < 2 || !flt) return false;
|
||||
const hasDetune = oscs.some(o => Math.abs(o.params.detune ?? 0) > 2);
|
||||
return hasDetune && flt.params.type === 'lowpass';
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 3,
|
||||
name: 'Riff completo',
|
||||
desc: 'Detuned saws < 70 Hz + LP + LFO al cutoff + envelope corto (decay < 0.3s)',
|
||||
test: (mods, conns) => {
|
||||
const oscs = mods.filter(m => m.type === 'oscillator' && m.params.waveform === 'sawtooth');
|
||||
const flt = mods.find(m => m.type === 'filter');
|
||||
const lfo = mods.find(m => m.type === 'lfo');
|
||||
const env = mods.find(m => m.type === 'envelope');
|
||||
if (oscs.length < 2 || !flt || !lfo || !env) return false;
|
||||
const allGrave = oscs.every(o => (o.params.frequency ?? 440) < 70);
|
||||
const hasDetune = oscs.some(o => Math.abs(o.params.detune ?? 0) > 2);
|
||||
const lfoToFlt = conns.some(c => c.from.moduleId === lfo.id && c.to.moduleId === flt.id && c.to.port === 'cutoff');
|
||||
return allGrave && hasDetune && lfoToFlt && (env.params.decay ?? 0.2) < 0.3;
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// ─────────────── LEVEL 7.3 ───────────────
|
||||
{
|
||||
id: 'w7-3',
|
||||
title: 'Sonido Pluck',
|
||||
subtitle: 'Notas percusivas secuenciadas',
|
||||
description: 'Un pluck es una nota corta y percusiva que decae rápido — como una gota de agua. Muy usado en lo que se llama "pluck bass" o "pluck lead". El secuenciador lo lanza en cadencia.',
|
||||
concept: 'Sequencer → Osc freq + Envelope gate. Osc square → Filter LP → VCA → Output. Envelope corto (attack 0, decay ~0.15s). LFO moderado al cutoff. El resultado: un sonido de gota de agua que repica.',
|
||||
availableModules: ['oscillator', 'filter', 'vca', 'lfo', 'envelope', 'sequencer'],
|
||||
preplacedModules: [
|
||||
{ id: 1, type: 'output', x: 750, y: 120, params: { volume: -8 }, locked: true },
|
||||
],
|
||||
target: {
|
||||
build: [
|
||||
{ type: 'oscillator', params: { waveform: 'square', frequency: 220 } },
|
||||
],
|
||||
filter: { type: 'lowpass', frequency: 2000, Q: 2 },
|
||||
envelope: { attack: 0.005, decay: 0.15, sustain: 0.05, release: 0.08 },
|
||||
lfo: { frequency: 2.5, type: 'sine', min: 1000, max: 4000, target: 'frequency' },
|
||||
triggerPattern: { interval: 0.375, count: 7 },
|
||||
duration: 2.5,
|
||||
},
|
||||
checks: [
|
||||
{
|
||||
star: 1,
|
||||
name: 'Pluck básico',
|
||||
desc: 'Sequencer → Osc → Filter → VCA → Output',
|
||||
test: (mods, conns) => {
|
||||
const seq = mods.find(m => m.type === 'sequencer');
|
||||
const osc = mods.find(m => m.type === 'oscillator');
|
||||
const flt = mods.find(m => m.type === 'filter');
|
||||
const vca = mods.find(m => m.type === 'vca');
|
||||
if (!seq || !osc || !flt || !vca) return false;
|
||||
return conns.some(c => c.from.moduleId === seq.id && c.to.moduleId === osc.id) &&
|
||||
conns.some(c => c.from.moduleId === flt.id && c.to.moduleId === vca.id);
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 2,
|
||||
name: 'Percusivo',
|
||||
desc: 'Envelope muy corto (decay < 0.2s, sustain < 0.1)',
|
||||
test: (mods) => {
|
||||
const env = mods.find(m => m.type === 'envelope');
|
||||
if (!env) return false;
|
||||
return (env.params.decay ?? 0.2) < 0.2 && (env.params.sustain ?? 0.5) < 0.1;
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 3,
|
||||
name: 'Pluck líquido',
|
||||
desc: 'Square osc, filtro LP + LFO al cutoff, envelope (attack 0, decay 0.1-0.2s)',
|
||||
test: (mods, conns) => {
|
||||
const seq = mods.find(m => m.type === 'sequencer');
|
||||
const osc = mods.find(m => m.type === 'oscillator');
|
||||
const flt = mods.find(m => m.type === 'filter');
|
||||
const lfo = mods.find(m => m.type === 'lfo');
|
||||
const env = mods.find(m => m.type === 'envelope');
|
||||
if (!seq || !osc || !flt || !lfo || !env) return false;
|
||||
const decay = env.params.decay ?? 0.2;
|
||||
const lfoToFlt = conns.some(c => c.from.moduleId === lfo.id && c.to.moduleId === flt.id && c.to.port === 'cutoff');
|
||||
return osc.params.waveform === 'square' && flt.params.type === 'lowpass' &&
|
||||
(env.params.attack ?? 0.01) <= 0.01 && decay >= 0.1 && decay <= 0.2 && lfoToFlt;
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// ─────────────── LEVEL 7.4 ───────────────
|
||||
{
|
||||
id: 'w7-4',
|
||||
title: 'Secuencia Filtrada',
|
||||
subtitle: 'Caja de ritmo sintética',
|
||||
description: 'Una variación del secuenciador: envía frecuencias a un filtro en lugar de (o además de) un oscilador. Esto crea sonidos únicos — casi como un sintetizador de ritmos donde el sonido source es fijo pero el filtro lo transforma.',
|
||||
concept: 'Noise → Filter LP → VCA → Output. Sequencer al cutoff del filtro (modula en tiempo real). Envelope al VCA. El resultado: un instrumento de ritmo completamente nuevo.',
|
||||
availableModules: ['noise', 'filter', 'vca', 'envelope', 'sequencer'],
|
||||
preplacedModules: [
|
||||
{ id: 1, type: 'output', x: 700, y: 120, params: { volume: -8 }, locked: true },
|
||||
],
|
||||
target: {
|
||||
build: [
|
||||
{ type: 'noise', params: { type: 'white' } },
|
||||
],
|
||||
filter: { type: 'lowpass', frequency: 1000, Q: 3.5 },
|
||||
envelope: { attack: 0, decay: 0.1, sustain: 0, release: 0 },
|
||||
triggerPattern: { interval: 0.5, count: 4 },
|
||||
duration: 2,
|
||||
},
|
||||
checks: [
|
||||
{
|
||||
star: 1,
|
||||
name: 'Noise secuenciado',
|
||||
desc: 'Sequencer modula el cutoff del filtro',
|
||||
test: (mods, conns) => {
|
||||
const seq = mods.find(m => m.type === 'sequencer');
|
||||
const noise = mods.find(m => m.type === 'noise');
|
||||
const flt = mods.find(m => m.type === 'filter');
|
||||
if (!seq || !noise || !flt) return false;
|
||||
return conns.some(c => c.from.moduleId === seq.id && c.to.moduleId === flt.id && c.to.port === 'cutoff');
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 2,
|
||||
name: 'Con envelope',
|
||||
desc: 'Noise → Filter → VCA con envelope al VCA',
|
||||
test: (mods, conns) => {
|
||||
const noise = mods.find(m => m.type === 'noise');
|
||||
const flt = mods.find(m => m.type === 'filter');
|
||||
const vca = mods.find(m => m.type === 'vca');
|
||||
const env = mods.find(m => m.type === 'envelope');
|
||||
if (!noise || !flt || !vca || !env) return false;
|
||||
return conns.some(c => c.from.moduleId === flt.id && c.to.moduleId === vca.id) &&
|
||||
conns.some(c => c.from.moduleId === env.id && c.to.moduleId === vca.id && c.to.port === 'cv');
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 3,
|
||||
name: 'Caja de ritmo',
|
||||
desc: 'Sequencer 16 steps, filtro con resonancia (Q > 2), envelope corto',
|
||||
test: (mods) => {
|
||||
const seq = mods.find(m => m.type === 'sequencer');
|
||||
const flt = mods.find(m => m.type === 'filter');
|
||||
const env = mods.find(m => m.type === 'envelope');
|
||||
if (!seq || !flt || !env) return false;
|
||||
return (seq.params.steps ?? '16') === '16' &&
|
||||
(flt.params.Q ?? 1) > 2 &&
|
||||
(env.params.decay ?? 0.2) < 0.15;
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// ─────────────── LEVEL 7.5 ───────────────
|
||||
{
|
||||
id: 'w7-5',
|
||||
title: 'Kick Secuenciado',
|
||||
subtitle: 'El corazón del beat',
|
||||
description: 'Ahora combina lo aprendido: usa el secuenciador para disparar un kick drum completo. El kick es simple: oscilador sine grave + envelope rápido. El secuenciador lo mantiene en ritmo.',
|
||||
concept: 'Sequencer gate → Envelope → Osc sine (40-60 Hz) + VCA → Output. El envelope dispara en cada paso. Parecido al kick de la sección anterior, pero secuenciado.',
|
||||
availableModules: ['oscillator', 'vca', 'envelope', 'sequencer'],
|
||||
preplacedModules: [
|
||||
{ id: 1, type: 'output', x: 700, y: 120, params: { volume: -8 }, locked: true },
|
||||
],
|
||||
target: {
|
||||
build: [
|
||||
{ type: 'oscillator', params: { waveform: 'sine', frequency: 50 } },
|
||||
],
|
||||
envelope: { attack: 0, decay: 0.3, sustain: 0, release: 0.1 },
|
||||
triggerPattern: { interval: 1, count: 2 },
|
||||
duration: 2,
|
||||
},
|
||||
checks: [
|
||||
{
|
||||
star: 1,
|
||||
name: 'Kick básico',
|
||||
desc: 'Sequencer → Envelope → Osc + VCA → Output',
|
||||
test: (mods, conns) => {
|
||||
const seq = mods.find(m => m.type === 'sequencer');
|
||||
const osc = mods.find(m => m.type === 'oscillator');
|
||||
const vca = mods.find(m => m.type === 'vca');
|
||||
const env = mods.find(m => m.type === 'envelope');
|
||||
if (!seq || !osc || !vca || !env) return false;
|
||||
return conns.some(c => c.from.moduleId === seq.id && c.to.moduleId === env.id && c.to.port === 'gate');
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 2,
|
||||
name: 'Sine grave',
|
||||
desc: 'Oscilador sine < 100 Hz, envelope rápido (decay < 0.4s)',
|
||||
test: (mods) => {
|
||||
const osc = mods.find(m => m.type === 'oscillator');
|
||||
const env = mods.find(m => m.type === 'envelope');
|
||||
if (!osc || !env) return false;
|
||||
return osc.params.waveform === 'sine' &&
|
||||
(osc.params.frequency ?? 440) < 100 &&
|
||||
(env.params.decay ?? 0.2) < 0.4;
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 3,
|
||||
name: '808 rítmico',
|
||||
desc: 'Sine 40-60 Hz, decay 0.2-0.4s, sequencer 140 BPM',
|
||||
test: (mods) => {
|
||||
const seq = mods.find(m => m.type === 'sequencer');
|
||||
const osc = mods.find(m => m.type === 'oscillator');
|
||||
const env = mods.find(m => m.type === 'envelope');
|
||||
if (!seq || !osc || !env) return false;
|
||||
const freq = osc.params.frequency ?? 440;
|
||||
const decay = env.params.decay ?? 0.2;
|
||||
return (seq.params.bpm ?? 140) === 140 &&
|
||||
freq >= 40 && freq <= 60 &&
|
||||
decay >= 0.2 && decay <= 0.4;
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// ─────────────── LEVEL 7.6 ───────────────
|
||||
{
|
||||
id: 'w7-6',
|
||||
title: 'Swing y Shuffle',
|
||||
subtitle: 'Humaniza tu beat',
|
||||
description: 'El swing es el parámetro que hace que un beat metrónomico suene más humano — desplaza ligeramente ciertos pasos. El shuffle crea ese groove de jazz o swing hip-hop. El secuenciador tiene ambos.',
|
||||
concept: 'Sequencer con swing > 0 crea una sensación de shuffle. Úsalo en un patrón simple: kick, hi-hat, snare. El resultado: una música que fluye, no una máquina rígida.',
|
||||
availableModules: ['noise', 'filter', 'oscillator', 'vca', 'envelope', 'sequencer'],
|
||||
preplacedModules: [
|
||||
{ id: 1, type: 'output', x: 750, y: 120, params: { volume: -8 }, locked: true },
|
||||
],
|
||||
target: {
|
||||
build: [
|
||||
{ type: 'oscillator', params: { waveform: 'sine', frequency: 55 } },
|
||||
{ type: 'noise', params: { type: 'white' } },
|
||||
],
|
||||
filter: { type: 'highpass', frequency: 6500, Q: 1.5 },
|
||||
envelope: { attack: 0, decay: 0.08, sustain: 0, release: 0 },
|
||||
triggerPattern: { interval: 0.5, count: 5 },
|
||||
duration: 2.5,
|
||||
},
|
||||
checks: [
|
||||
{
|
||||
star: 1,
|
||||
name: 'Sequenciador con swing',
|
||||
desc: 'Sequencer con parámetro swing > 0',
|
||||
test: (mods) => {
|
||||
const seq = mods.find(m => m.type === 'sequencer');
|
||||
if (!seq) return false;
|
||||
return (seq.params.swing ?? 0) > 0;
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 2,
|
||||
name: 'Dos capas de ritmo',
|
||||
desc: 'Al menos 2 fuentes de sonido (kick + hi-hat, por ejemplo)',
|
||||
test: (mods, conns) => {
|
||||
const seq = mods.find(m => m.type === 'sequencer');
|
||||
const oscs = mods.filter(m => m.type === 'oscillator');
|
||||
const noise = mods.find(m => m.type === 'noise');
|
||||
const vcas = mods.filter(m => m.type === 'vca');
|
||||
if (!seq) return false;
|
||||
const sources = (oscs.length > 0 ? 1 : 0) + (noise ? 1 : 0);
|
||||
return sources >= 2 && vcas.length >= 2;
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 3,
|
||||
name: 'Groove profesional',
|
||||
desc: 'Swing 15+, 2+ fuentes, envelope distintos (uno corto, uno más largo)',
|
||||
test: (mods) => {
|
||||
const seq = mods.find(m => m.type === 'sequencer');
|
||||
const envs = mods.filter(m => m.type === 'envelope');
|
||||
if (!seq || envs.length < 2) return false;
|
||||
const swing = seq.params.swing ?? 0;
|
||||
const decays = envs.map(e => e.params.decay ?? 0.2);
|
||||
const decayDiff = Math.max(...decays) - Math.min(...decays);
|
||||
return swing >= 15 && decayDiff > 0.05;
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// ─────────────── LEVEL 7.7 ───────────────
|
||||
{
|
||||
id: 'w7-7',
|
||||
title: 'Delay en Secuencia',
|
||||
subtitle: 'Ecos secuenciados',
|
||||
description: 'Añade un delay a una secuencia. El delay repite el sonido secuenciado, creando una cola de ecos que se desvanecen. Muy usado en trance, techno y música electrónica para darle profundidad.',
|
||||
concept: 'Secuencia normal → Delay → Output. El delay time se puede sincronizar al BPM del secuenciador para ecos en tiempo. Feedback controla cuántas repeticiones. Wet controla qué tan presente están los ecos.',
|
||||
availableModules: ['oscillator', 'filter', 'vca', 'envelope', 'sequencer', 'delay'],
|
||||
preplacedModules: [
|
||||
{ id: 1, type: 'output', x: 800, y: 120, params: { volume: -8 }, locked: true },
|
||||
],
|
||||
target: {
|
||||
build: [
|
||||
{ type: 'oscillator', params: { waveform: 'sine', frequency: 440 } },
|
||||
],
|
||||
filter: { type: 'lowpass', frequency: 1500, Q: 2 },
|
||||
envelope: { attack: 0.01, decay: 0.12, sustain: 0, release: 0.05 },
|
||||
effects: [
|
||||
{ type: 'delay', time: 0.35, feedback: 0.5, wet: 0.55 },
|
||||
],
|
||||
triggerPattern: { interval: 0.5, count: 6 },
|
||||
duration: 3,
|
||||
},
|
||||
checks: [
|
||||
{
|
||||
star: 1,
|
||||
name: 'Delay en cadena',
|
||||
desc: 'Sequencer → Osc → VCA → Delay → Output',
|
||||
test: (mods, conns) => {
|
||||
const seq = mods.find(m => m.type === 'sequencer');
|
||||
const del = mods.find(m => m.type === 'delay');
|
||||
const out = mods.find(m => m.type === 'output');
|
||||
if (!seq || !del || !out) return false;
|
||||
return conns.some(c => c.to.moduleId === del.id) &&
|
||||
conns.some(c => c.from.moduleId === del.id && c.to.moduleId === out.id);
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 2,
|
||||
name: 'Con retroalimentación',
|
||||
desc: 'Delay con feedback > 0.3 para ecos múltiples',
|
||||
test: (mods) => {
|
||||
const del = mods.find(m => m.type === 'delay');
|
||||
if (!del) return false;
|
||||
return (del.params.feedback ?? 0.4) > 0.3;
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 3,
|
||||
name: 'Eco profundo',
|
||||
desc: 'Delay time 0.2-0.5s, feedback 0.4-0.8, wet > 0.4, filtro en la cadena',
|
||||
test: (mods) => {
|
||||
const del = mods.find(m => m.type === 'delay');
|
||||
const flt = mods.find(m => m.type === 'filter');
|
||||
if (!del || !flt) return false;
|
||||
const delayTime = del.params.delayTime ?? 0.3;
|
||||
const feedback = del.params.feedback ?? 0.4;
|
||||
const wet = del.params.wet ?? 0.5;
|
||||
return delayTime >= 0.2 && delayTime <= 0.5 &&
|
||||
feedback >= 0.4 && feedback <= 0.8 &&
|
||||
wet > 0.4;
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// ─────────────── LEVEL 7.8: BOSS ───────────────
|
||||
{
|
||||
id: 'w7-8',
|
||||
title: 'Beat Completo',
|
||||
subtitle: 'BOSS FINAL: Tu canción',
|
||||
description: 'Ahora junta todo: un secuenciador principal, un kick, un hi-hat, un bajo secuenciado y al menos un efecto. Crea un beat completo que suene profesional — ritmo, groove, profundidad.',
|
||||
concept: 'Secuenciador 140 BPM con swing. Kick drum (sine < 60 Hz + envelope rápido). Hi-hat (noise + filter HP + envelope corto). Bajo secuenciado (2 oscs detuned + filter). Delay o reverb. Mixer si es necesario.',
|
||||
availableModules: ['oscillator', 'noise', 'filter', 'vca', 'envelope', 'sequencer', 'mixer', 'delay', 'reverb', 'distortion', 'lfo'],
|
||||
preplacedModules: [
|
||||
{ id: 1, type: 'output', x: 900, y: 140, params: { volume: -8 }, locked: true },
|
||||
],
|
||||
target: {
|
||||
build: [
|
||||
{ type: 'oscillator', params: { waveform: 'sine', frequency: 50 } },
|
||||
{ type: 'noise', params: { type: 'white' } },
|
||||
{ type: 'oscillator', params: { waveform: 'sawtooth', frequency: 55, detune: -8 } },
|
||||
{ type: 'oscillator', params: { waveform: 'sawtooth', frequency: 55, detune: 8 } },
|
||||
],
|
||||
filter: { type: 'highpass', frequency: 7000, Q: 1.5 },
|
||||
envelope: { attack: 0, decay: 0.15, sustain: 0, release: 0.05 },
|
||||
effects: [
|
||||
{ type: 'delay', time: 0.3, feedback: 0.4, wet: 0.35 },
|
||||
{ type: 'reverb', decay: 2.5, wet: 0.25 },
|
||||
],
|
||||
triggerPattern: { interval: 1, count: 5 },
|
||||
duration: 5,
|
||||
},
|
||||
checks: [
|
||||
{
|
||||
star: 1,
|
||||
name: 'Beat funcional',
|
||||
desc: 'Sequencer + 3 capas de sonido (kick, hi-hat, bass) → Output',
|
||||
test: (mods, conns) => {
|
||||
const seq = mods.find(m => m.type === 'sequencer');
|
||||
const out = mods.find(m => m.type === 'output');
|
||||
const vcas = mods.filter(m => m.type === 'vca');
|
||||
if (!seq || !out || vcas.length < 3) return false;
|
||||
const modCount = mods.filter(m => m.type !== 'output').length;
|
||||
return modCount >= 10 && conns.some(c => c.to.moduleId === out.id);
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 2,
|
||||
name: 'Kick + Hi-hat + Bajo',
|
||||
desc: 'Oscillator sine + noise + 2 oscs detuned, todos con envelopes',
|
||||
test: (mods) => {
|
||||
const oscs = mods.filter(m => m.type === 'oscillator');
|
||||
const noise = mods.find(m => m.type === 'noise');
|
||||
const envs = mods.filter(m => m.type === 'envelope');
|
||||
if (oscs.length < 3 || !noise || envs.length < 3) return false;
|
||||
const sines = oscs.filter(o => o.params.waveform === 'sine');
|
||||
const detuned = oscs.filter(o => Math.abs(o.params.detune ?? 0) > 2);
|
||||
return sines.length > 0 && detuned.length >= 2;
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 3,
|
||||
name: 'Maestro del Ritmo',
|
||||
desc: '140 BPM, swing 15+, kick sine < 60 Hz, hi-hat noise HP > 5000 Hz, bass detuned, delay o reverb',
|
||||
test: (mods, conns) => {
|
||||
const seq = mods.find(m => m.type === 'sequencer');
|
||||
const sineOscs = mods.filter(m => m.type === 'oscillator' && m.params.waveform === 'sine');
|
||||
const detunedOscs = mods.filter(m => m.type === 'oscillator' && Math.abs(m.params.detune ?? 0) > 2);
|
||||
const hpFilter = mods.find(m => m.type === 'filter' && m.params.type === 'highpass');
|
||||
const effects = mods.filter(m => ['delay', 'reverb'].includes(m.type));
|
||||
if (!seq || sineOscs.length === 0 || detunedOscs.length < 2 || !hpFilter || effects.length === 0) return false;
|
||||
const kick = sineOscs.find(o => (o.params.frequency ?? 440) < 60);
|
||||
const hpCutoff = hpFilter.params.frequency ?? 1000;
|
||||
return (seq.params.bpm ?? 140) === 140 &&
|
||||
(seq.params.swing ?? 0) >= 15 &&
|
||||
kick !== undefined &&
|
||||
hpCutoff > 5000;
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
537
packages/client/src/game/levels/world8.js
Normal file
537
packages/client/src/game/levels/world8.js
Normal file
@@ -0,0 +1,537 @@
|
||||
/**
|
||||
* World 8 — "Texturas de Ruido" (Noise Textures)
|
||||
*
|
||||
* Teaches: noise types, wind sounds (bandpass), ocean waves (LFO on cutoff),
|
||||
* rain (noise + short envelope), radio static (noise + distortion),
|
||||
* industrial rhythm (noise + LFO on VCA), ambient texture (noise + reverb + delay)
|
||||
* 8 levels + boss challenge: "Paisaje Sonoro" (Soundscape)
|
||||
*/
|
||||
|
||||
export const WORLD_8 = {
|
||||
id: 'w8',
|
||||
name: 'Texturas de Ruido',
|
||||
subtitle: 'Más allá de las notas',
|
||||
icon: '⣿',
|
||||
color: '#88aaff',
|
||||
unlockStars: 84,
|
||||
levels: [
|
||||
// ─────────────── LEVEL 8.1 ───────────────
|
||||
{
|
||||
id: 'w8-1',
|
||||
title: 'Ruido Blanco',
|
||||
subtitle: 'El sonido puro',
|
||||
description: 'El ruido blanco es aleatoriedad pura — todas las frecuencias con igual intensidad. Suena como estática de TV o lluvia lejana. Es el punto de partida para texturas ruidosas.',
|
||||
concept: 'Noise (tipo "white") → VCA → Output. Envelope al VCA. Sonido: "sssshhhhh" — simple pero bonito. Es la base de muchas texturas.',
|
||||
availableModules: ['noise', 'vca', 'envelope'],
|
||||
preplacedModules: [
|
||||
{ id: 1, type: 'output', x: 700, y: 120, params: { volume: -8 }, locked: true },
|
||||
],
|
||||
target: {
|
||||
build: [
|
||||
{ type: 'noise', params: { type: 'white' } },
|
||||
],
|
||||
envelope: { attack: 0.1, decay: 0.3, sustain: 0.1, release: 0.2 },
|
||||
duration: 1.5,
|
||||
},
|
||||
checks: [
|
||||
{
|
||||
star: 1,
|
||||
name: 'Ruido básico',
|
||||
desc: 'Noise → VCA → Output',
|
||||
test: (mods, conns) => {
|
||||
const noise = mods.find(m => m.type === 'noise');
|
||||
const vca = mods.find(m => m.type === 'vca');
|
||||
const out = mods.find(m => m.type === 'output');
|
||||
if (!noise || !vca || !out) return false;
|
||||
return conns.some(c => c.from.moduleId === noise.id && c.to.moduleId === vca.id) &&
|
||||
conns.some(c => c.from.moduleId === vca.id && c.to.moduleId === out.id);
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 2,
|
||||
name: 'Con envelope',
|
||||
desc: 'Envelope dispara el VCA',
|
||||
test: (mods, conns) => {
|
||||
const env = mods.find(m => m.type === 'envelope');
|
||||
const vca = mods.find(m => m.type === 'vca');
|
||||
if (!env || !vca) return false;
|
||||
return conns.some(c => c.from.moduleId === env.id && c.to.moduleId === vca.id && c.to.port === 'cv');
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 3,
|
||||
name: 'Ruido controlado',
|
||||
desc: 'Noise white + envelope con attack suave (< 0.1s), decay moderado (0.2-0.5s)',
|
||||
test: (mods) => {
|
||||
const noise = mods.find(m => m.type === 'noise');
|
||||
const env = mods.find(m => m.type === 'envelope');
|
||||
if (!noise || !env) return false;
|
||||
const attack = env.params.attack ?? 0.01;
|
||||
const decay = env.params.decay ?? 0.2;
|
||||
return noise.params.type === 'white' && attack < 0.1 && decay >= 0.2 && decay <= 0.5;
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// ─────────────── LEVEL 8.2 ───────────────
|
||||
{
|
||||
id: 'w8-2',
|
||||
title: 'Sonido de Viento',
|
||||
subtitle: 'Brisa y vendavales',
|
||||
description: 'El viento es ruido filtrado con un filtro bandpass — solo un rango de frecuencias pasa. Varías el cutoff y Q para cambiar el "tipo" de viento (brisa suave vs. huracán).',
|
||||
concept: 'Noise → Filter bandpass (cutoff ~3000 Hz, Q moderado ~3-5) → VCA → Output. Envelope suave al VCA. Resultado: "whoooosh", viento realista.',
|
||||
availableModules: ['noise', 'filter', 'vca', 'envelope'],
|
||||
preplacedModules: [
|
||||
{ id: 1, type: 'output', x: 700, y: 120, params: { volume: -8 }, locked: true },
|
||||
],
|
||||
target: {
|
||||
build: [
|
||||
{ type: 'noise', params: { type: 'white' } },
|
||||
],
|
||||
filter: { type: 'bandpass', frequency: 3000, Q: 4 },
|
||||
envelope: { attack: 0.15, decay: 0.6, sustain: 0.05, release: 0.3 },
|
||||
duration: 1.5,
|
||||
},
|
||||
checks: [
|
||||
{
|
||||
star: 1,
|
||||
name: 'Ruido filtrado',
|
||||
desc: 'Noise → Filter bandpass → VCA → Output',
|
||||
test: (mods, conns) => {
|
||||
const noise = mods.find(m => m.type === 'noise');
|
||||
const flt = mods.find(m => m.type === 'filter');
|
||||
const vca = mods.find(m => m.type === 'vca');
|
||||
if (!noise || !flt || !vca) return false;
|
||||
return flt.params.type === 'bandpass' &&
|
||||
conns.some(c => c.from.moduleId === noise.id && c.to.moduleId === flt.id) &&
|
||||
conns.some(c => c.from.moduleId === flt.id && c.to.moduleId === vca.id);
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 2,
|
||||
name: 'Con resonancia',
|
||||
desc: 'Filtro bandpass con Q > 2',
|
||||
test: (mods) => {
|
||||
const flt = mods.find(m => m.type === 'filter');
|
||||
if (!flt) return false;
|
||||
return flt.params.type === 'bandpass' && (flt.params.Q ?? 1) > 2;
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 3,
|
||||
name: 'Viento realista',
|
||||
desc: 'Bandpass 2000-4000 Hz, Q 3-5, envelope suave (attack 0.1-0.2s, decay 0.5+)',
|
||||
test: (mods) => {
|
||||
const flt = mods.find(m => m.type === 'filter');
|
||||
const env = mods.find(m => m.type === 'envelope');
|
||||
if (!flt || !env) return false;
|
||||
const cutoff = flt.params.frequency ?? 1000;
|
||||
const Q = flt.params.Q ?? 1;
|
||||
const attack = env.params.attack ?? 0.01;
|
||||
const decay = env.params.decay ?? 0.2;
|
||||
return cutoff >= 2000 && cutoff <= 4000 && Q >= 3 && Q <= 5 &&
|
||||
attack >= 0.1 && attack <= 0.2 && decay >= 0.5;
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// ─────────────── LEVEL 8.3 ───────────────
|
||||
{
|
||||
id: 'w8-3',
|
||||
title: 'Olas del Océano',
|
||||
subtitle: 'LFO al cutoff',
|
||||
description: 'El océano "respira" — la amplitud cambia lentamente. Se logra modulando el cutoff del filtro con un LFO muy lento (~0.2-0.5 Hz). El resultado: un sonido que crece y disminuye como olas.',
|
||||
concept: 'Noise → Filter LP → VCA → Output. LFO lento (0.2-0.5 Hz) al cutoff del filtro. Envelope suave al VCA. Resultado: un sonido hipnótico que respira.',
|
||||
availableModules: ['noise', 'filter', 'vca', 'lfo', 'envelope'],
|
||||
preplacedModules: [
|
||||
{ id: 1, type: 'output', x: 750, y: 120, params: { volume: -8 }, locked: true },
|
||||
],
|
||||
target: {
|
||||
build: [
|
||||
{ type: 'noise', params: { type: 'white' } },
|
||||
],
|
||||
filter: { type: 'lowpass', frequency: 1200, Q: 1 },
|
||||
lfo: { frequency: 0.3, type: 'sine', min: 500, max: 2500, target: 'frequency' },
|
||||
envelope: { attack: 0.2, decay: 0.5, sustain: 0.3, release: 0.4 },
|
||||
duration: 2,
|
||||
},
|
||||
checks: [
|
||||
{
|
||||
star: 1,
|
||||
name: 'LFO al filtro',
|
||||
desc: 'Noise → Filter → VCA. LFO al cutoff del filtro',
|
||||
test: (mods, conns) => {
|
||||
const noise = mods.find(m => m.type === 'noise');
|
||||
const flt = mods.find(m => m.type === 'filter');
|
||||
const lfo = mods.find(m => m.type === 'lfo');
|
||||
if (!noise || !flt || !lfo) return false;
|
||||
return conns.some(c => c.from.moduleId === lfo.id && c.to.moduleId === flt.id && c.to.port === 'cutoff');
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 2,
|
||||
name: 'LFO lento',
|
||||
desc: 'LFO con frequency < 1 Hz para movimiento lento',
|
||||
test: (mods) => {
|
||||
const lfo = mods.find(m => m.type === 'lfo');
|
||||
if (!lfo) return false;
|
||||
return (lfo.params.frequency ?? 2) < 1;
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 3,
|
||||
name: 'Olas hipnóticas',
|
||||
desc: 'LFO 0.2-0.5 Hz, filtro LP cutoff 500-3000 Hz, envelope suave (decay 1+)',
|
||||
test: (mods) => {
|
||||
const lfo = mods.find(m => m.type === 'lfo');
|
||||
const flt = mods.find(m => m.type === 'filter');
|
||||
const env = mods.find(m => m.type === 'envelope');
|
||||
if (!lfo || !flt || !env) return false;
|
||||
const lfoFreq = lfo.params.frequency ?? 2;
|
||||
const cutoff = flt.params.frequency ?? 1000;
|
||||
const decay = env.params.decay ?? 0.2;
|
||||
return lfoFreq >= 0.2 && lfoFreq <= 0.5 &&
|
||||
cutoff >= 500 && cutoff <= 3000 &&
|
||||
flt.params.type === 'lowpass' &&
|
||||
decay >= 1;
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// ─────────────── LEVEL 8.4 ───────────────
|
||||
{
|
||||
id: 'w8-4',
|
||||
title: 'Sonido de Lluvia',
|
||||
subtitle: 'Gotas percusivas',
|
||||
description: 'La lluvia es ruido + un envelope muy corto que dispara múltiples veces. Cada "gota" es un ataque y decaimiento rápidos. Varias gotas creadas con los mismos parámetros generan una ilusión de lluvia.',
|
||||
concept: 'Noise → VCA → Output. Envelope CORTO (attack 0, decay ~0.05-0.1s, sustain 0) al VCA. Un keyboard para disparar "gotas". Varias pulsaciones = lluvia.',
|
||||
availableModules: ['noise', 'vca', 'envelope', 'keyboard'],
|
||||
preplacedModules: [
|
||||
{ id: 1, type: 'output', x: 700, y: 120, params: { volume: -10 }, locked: true },
|
||||
],
|
||||
target: {
|
||||
build: [
|
||||
{ type: 'noise', params: { type: 'white' } },
|
||||
],
|
||||
envelope: { attack: 0.01, decay: 0.06, sustain: 0, release: 0.02 },
|
||||
triggerPattern: { interval: 0.15, count: 1 },
|
||||
duration: 1.5,
|
||||
},
|
||||
checks: [
|
||||
{
|
||||
star: 1,
|
||||
name: 'Gota de lluvia',
|
||||
desc: 'Noise → VCA con envelope corto (decay < 0.15s)',
|
||||
test: (mods, conns) => {
|
||||
const noise = mods.find(m => m.type === 'noise');
|
||||
const vca = mods.find(m => m.type === 'vca');
|
||||
const env = mods.find(m => m.type === 'envelope');
|
||||
if (!noise || !vca || !env) return false;
|
||||
return conns.some(c => c.from.moduleId === env.id && c.to.moduleId === vca.id && c.to.port === 'cv') &&
|
||||
(env.params.decay ?? 0.2) < 0.15;
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 2,
|
||||
name: 'Percusivo',
|
||||
desc: 'Envelope con attack 0, decay 0.05-0.1s, sustain 0',
|
||||
test: (mods) => {
|
||||
const env = mods.find(m => m.type === 'envelope');
|
||||
if (!env) return false;
|
||||
const decay = env.params.decay ?? 0.2;
|
||||
const sustain = env.params.sustain ?? 0.5;
|
||||
return (env.params.attack ?? 0.01) <= 0.01 && decay >= 0.05 && decay <= 0.1 && sustain < 0.05;
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 3,
|
||||
name: 'Lluvia realista',
|
||||
desc: 'Noise white, envelope ultra-corto (decay 0.03-0.08s), keyboard conectado',
|
||||
test: (mods, conns) => {
|
||||
const noise = mods.find(m => m.type === 'noise');
|
||||
const env = mods.find(m => m.type === 'envelope');
|
||||
const kb = mods.find(m => m.type === 'keyboard');
|
||||
if (!noise || !env || !kb) return false;
|
||||
const decay = env.params.decay ?? 0.2;
|
||||
const connected = conns.some(c => c.from.moduleId === kb.id && c.to.moduleId === env.id && c.to.port === 'gate');
|
||||
return noise.params.type === 'white' && decay >= 0.03 && decay <= 0.08 && connected;
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// ─────────────── LEVEL 8.5 ───────────────
|
||||
{
|
||||
id: 'w8-5',
|
||||
title: 'Estática de Radio',
|
||||
subtitle: 'Ruido + Distorsión',
|
||||
description: 'La estática de radio es ruido MÁS distorsión — un efecto que "rompe" el sonido de forma agresiva. Crea ese sonido crispante, lo-fi, de radio rota o síntesis glitch.',
|
||||
concept: 'Noise → Distortion (distortion 0.6+) → VCA → Output. Envelope al VCA. La distorsión enfatiza ciertas partes del ruido, creando un sonido más agresivo y texturado.',
|
||||
availableModules: ['noise', 'vca', 'envelope', 'distortion'],
|
||||
preplacedModules: [
|
||||
{ id: 1, type: 'output', x: 700, y: 120, params: { volume: -12 }, locked: true },
|
||||
],
|
||||
target: {
|
||||
build: [
|
||||
{ type: 'noise', params: { type: 'white' } },
|
||||
],
|
||||
effects: [
|
||||
{ type: 'distortion', distortion: 0.75, wet: 0.7 },
|
||||
],
|
||||
envelope: { attack: 0.08, decay: 0.6, sustain: 0.1, release: 0.25 },
|
||||
duration: 1.5,
|
||||
},
|
||||
checks: [
|
||||
{
|
||||
star: 1,
|
||||
name: 'Ruido distorsionado',
|
||||
desc: 'Noise → Distortion → VCA → Output',
|
||||
test: (mods, conns) => {
|
||||
const noise = mods.find(m => m.type === 'noise');
|
||||
const dist = mods.find(m => m.type === 'distortion');
|
||||
const vca = mods.find(m => m.type === 'vca');
|
||||
if (!noise || !dist || !vca) return false;
|
||||
return conns.some(c => c.from.moduleId === noise.id && c.to.moduleId === dist.id) &&
|
||||
conns.some(c => c.from.moduleId === dist.id && c.to.moduleId === vca.id);
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 2,
|
||||
name: 'Agresivo',
|
||||
desc: 'Distorsión > 0.4 para un sonido roto',
|
||||
test: (mods) => {
|
||||
const dist = mods.find(m => m.type === 'distortion');
|
||||
if (!dist) return false;
|
||||
return (dist.params.distortion ?? 0.4) > 0.4;
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 3,
|
||||
name: 'Estática completa',
|
||||
desc: 'Distorsión 0.6-0.9, wet 0.6+, envelope suave (decay 0.5+)',
|
||||
test: (mods) => {
|
||||
const dist = mods.find(m => m.type === 'distortion');
|
||||
const env = mods.find(m => m.type === 'envelope');
|
||||
if (!dist || !env) return false;
|
||||
const distortion = dist.params.distortion ?? 0.4;
|
||||
const wet = dist.params.wet ?? 0.5;
|
||||
const decay = env.params.decay ?? 0.2;
|
||||
return distortion >= 0.6 && distortion <= 0.9 && wet >= 0.6 && decay >= 0.5;
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// ─────────────── LEVEL 8.6 ───────────────
|
||||
{
|
||||
id: 'w8-6',
|
||||
title: 'Ritmo Industrial',
|
||||
subtitle: 'LFO modulando VCA',
|
||||
description: 'Ahora modulamos el VCA con un LFO en lugar del envelope — crea un efecto de "pulsación" o "tremolo". Combined con noise, crea un sonido industrial, maquínico, hipnótico.',
|
||||
concept: 'Noise → VCA. LFO (frequency ~1-2 Hz) al CV del VCA. Resultado: el ruido sube y baja rítmicamente, como una máquina industrial.',
|
||||
availableModules: ['noise', 'vca', 'lfo'],
|
||||
preplacedModules: [
|
||||
{ id: 1, type: 'output', x: 700, y: 120, params: { volume: -8 }, locked: true },
|
||||
],
|
||||
target: {
|
||||
build: [
|
||||
{ type: 'noise', params: { type: 'pink' } },
|
||||
],
|
||||
lfo: { frequency: 1.5, type: 'square', min: 0.2, max: 1, target: 'amplitude' },
|
||||
duration: 1.5,
|
||||
},
|
||||
checks: [
|
||||
{
|
||||
star: 1,
|
||||
name: 'LFO al VCA',
|
||||
desc: 'Noise → VCA. LFO al CV del VCA',
|
||||
test: (mods, conns) => {
|
||||
const noise = mods.find(m => m.type === 'noise');
|
||||
const vca = mods.find(m => m.type === 'vca');
|
||||
const lfo = mods.find(m => m.type === 'lfo');
|
||||
if (!noise || !vca || !lfo) return false;
|
||||
return conns.some(c => c.from.moduleId === lfo.id && c.to.moduleId === vca.id && c.to.port === 'cv') &&
|
||||
conns.some(c => c.from.moduleId === noise.id && c.to.moduleId === vca.id && c.to.port === 'in');
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 2,
|
||||
name: 'Pulsación',
|
||||
desc: 'LFO frequency 0.5-3 Hz para tremolo audible',
|
||||
test: (mods) => {
|
||||
const lfo = mods.find(m => m.type === 'lfo');
|
||||
if (!lfo) return false;
|
||||
const freq = lfo.params.frequency ?? 2;
|
||||
return freq >= 0.5 && freq <= 3;
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 3,
|
||||
name: 'Industrial puro',
|
||||
desc: 'LFO 1-2 Hz, square waveform (si hay opción), amplitude > 0.5',
|
||||
test: (mods) => {
|
||||
const lfo = mods.find(m => m.type === 'lfo');
|
||||
if (!lfo) return false;
|
||||
const freq = lfo.params.frequency ?? 2;
|
||||
const amplitude = lfo.params.amplitude ?? 0.5;
|
||||
return freq >= 1 && freq <= 2 && amplitude > 0.5;
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// ─────────────── LEVEL 8.7 ───────────────
|
||||
{
|
||||
id: 'w8-7',
|
||||
title: 'Textura Ambiental',
|
||||
subtitle: 'Ruido + Reverb + Delay',
|
||||
description: 'Una textura ambiental es ruido filtrado + MUCHO reverb y delay. El reverb añade espacio (como un reverb de catedral), el delay crea repeticiones. El resultado: un sonido envolvente, envolvente, romántico.',
|
||||
concept: 'Noise → Filter LP (cutoff bajo ~1000 Hz) → Reverb (decay 4+) → Delay → Output. No necesitas envelope — deja que el sonido respire solo. Es puro ambiente.',
|
||||
availableModules: ['noise', 'filter', 'reverb', 'delay'],
|
||||
preplacedModules: [
|
||||
{ id: 1, type: 'output', x: 800, y: 120, params: { volume: -8 }, locked: true },
|
||||
],
|
||||
target: {
|
||||
build: [
|
||||
{ type: 'noise', params: { type: 'brown' } },
|
||||
],
|
||||
filter: { type: 'lowpass', frequency: 900, Q: 0.8 },
|
||||
effects: [
|
||||
{ type: 'delay', delayTime: 0.4, feedback: 0.45, wet: 0.6 },
|
||||
{ type: 'reverb', decay: 4.5, wet: 0.7 },
|
||||
],
|
||||
duration: 2.5,
|
||||
},
|
||||
checks: [
|
||||
{
|
||||
star: 1,
|
||||
name: 'Reverb en cadena',
|
||||
desc: 'Noise → Filter → Reverb → Output',
|
||||
test: (mods, conns) => {
|
||||
const noise = mods.find(m => m.type === 'noise');
|
||||
const flt = mods.find(m => m.type === 'filter');
|
||||
const rev = mods.find(m => m.type === 'reverb');
|
||||
if (!noise || !flt || !rev) return false;
|
||||
return conns.some(c => c.from.moduleId === flt.id && c.to.moduleId === rev.id) &&
|
||||
conns.some(c => c.from.moduleId === rev.id);
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 2,
|
||||
name: 'Espacioso',
|
||||
desc: 'Reverb decay > 3, delay en cadena también',
|
||||
test: (mods, conns) => {
|
||||
const rev = mods.find(m => m.type === 'reverb');
|
||||
const del = mods.find(m => m.type === 'delay');
|
||||
if (!rev || !del) return false;
|
||||
return (rev.params.decay ?? 2) > 3;
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 3,
|
||||
name: 'Ambiente etéreo',
|
||||
desc: 'LP < 1500 Hz, reverb decay 4+, delay feedback 0.4+, combinación crea sonido flotante',
|
||||
test: (mods) => {
|
||||
const flt = mods.find(m => m.type === 'filter');
|
||||
const rev = mods.find(m => m.type === 'reverb');
|
||||
const del = mods.find(m => m.type === 'delay');
|
||||
if (!flt || !rev || !del) return false;
|
||||
const cutoff = flt.params.frequency ?? 1000;
|
||||
const revDecay = rev.params.decay ?? 2;
|
||||
const delFeedback = del.params.feedback ?? 0.4;
|
||||
return cutoff <= 1500 && revDecay >= 4 && delFeedback >= 0.4;
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// ─────────────── LEVEL 8.8: BOSS ───────────────
|
||||
{
|
||||
id: 'w8-8',
|
||||
title: 'Paisaje Sonoro',
|
||||
subtitle: 'BOSS FINAL: Un mundo de sonido',
|
||||
description: 'Combina TODAS las texturas aprendidas en un único paisaje sonoro. Crea una composición con capas: viento, lluvia, olas, estática, ritmo industrial, ambiente. Una sinfonía de ruido y texturas.',
|
||||
concept: 'Mínimo 4 capas de ruido con diferentes características: 1) filtro bandpass (viento), 2) ruido + envelope corto (lluvia), 3) ruido + LFO al filtro (olas), 4) ruido + LFO al VCA (ritmo). Todo mezclado, con reverb y delay, fluyendo en armonía.',
|
||||
availableModules: ['noise', 'filter', 'vca', 'lfo', 'envelope', 'mixer', 'delay', 'reverb', 'distortion'],
|
||||
preplacedModules: [
|
||||
{ id: 1, type: 'output', x: 900, y: 140, params: { volume: -8 }, locked: true },
|
||||
],
|
||||
target: {
|
||||
build: [
|
||||
{ type: 'noise', params: { type: 'white' } },
|
||||
{ type: 'noise', params: { type: 'pink' } },
|
||||
{ type: 'noise', params: { type: 'brown' } },
|
||||
{ type: 'noise', params: { type: 'white' } },
|
||||
],
|
||||
filter: { type: 'bandpass', frequency: 2800, Q: 3.5 },
|
||||
lfo: [
|
||||
{ frequency: 0.35, type: 'sine', min: 600, max: 2200, target: 'frequency' },
|
||||
{ frequency: 1.2, type: 'square', min: 0.1, max: 0.9, target: 'amplitude' },
|
||||
],
|
||||
effects: [
|
||||
{ type: 'delay', delayTime: 0.35, feedback: 0.5, wet: 0.5 },
|
||||
{ type: 'reverb', decay: 3.5, wet: 0.55 },
|
||||
],
|
||||
envelope: { attack: 0.12, decay: 0.4, sustain: 0.2, release: 0.3 },
|
||||
duration: 6,
|
||||
},
|
||||
checks: [
|
||||
{
|
||||
star: 1,
|
||||
name: 'Múltiples texturas',
|
||||
desc: 'Al menos 3 canales de ruido con características diferentes, todos a output',
|
||||
test: (mods, conns) => {
|
||||
const noises = mods.filter(m => m.type === 'noise');
|
||||
const out = mods.find(m => m.type === 'output');
|
||||
if (noises.length < 3 || !out) return false;
|
||||
// Count different filter types or modulators
|
||||
const filters = mods.filter(m => m.type === 'filter');
|
||||
const lfos = mods.filter(m => m.type === 'lfo');
|
||||
const envs = mods.filter(m => m.type === 'envelope');
|
||||
const total = filters.length + lfos.length + envs.length;
|
||||
return total >= 3 && conns.some(c => c.to.moduleId === out.id);
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 2,
|
||||
name: 'Sonido espacioso',
|
||||
desc: 'Reverb y delay en cadena, crean profundidad y eco',
|
||||
test: (mods, conns) => {
|
||||
const rev = mods.find(m => m.type === 'reverb');
|
||||
const del = mods.find(m => m.type === 'delay');
|
||||
if (!rev || !del) return false;
|
||||
// At least one should connect to output or to each other
|
||||
const out = mods.find(m => m.type === 'output');
|
||||
return (conns.some(c => c.from.moduleId === rev.id && c.to.moduleId === del.id) ||
|
||||
conns.some(c => c.from.moduleId === del.id && c.to.moduleId === rev.id) ||
|
||||
(conns.some(c => c.from.moduleId === rev.id && c.to.moduleId === out?.id) &&
|
||||
conns.some(c => c.from.moduleId === del.id && c.to.moduleId === out?.id)));
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 3,
|
||||
name: 'Maestro de Texturas',
|
||||
desc: '4+ noises, 2+ filters, 2+ LFOs, mixer, reverb decay 3+, delay feedback 0.4+, distorsión opcional',
|
||||
test: (mods, conns) => {
|
||||
const noises = mods.filter(m => m.type === 'noise');
|
||||
const filters = mods.filter(m => m.type === 'filter');
|
||||
const lfos = mods.filter(m => m.type === 'lfo');
|
||||
const mixer = mods.find(m => m.type === 'mixer');
|
||||
const rev = mods.find(m => m.type === 'reverb');
|
||||
const del = mods.find(m => m.type === 'delay');
|
||||
const nonOutput = mods.filter(m => m.type !== 'output');
|
||||
if (noises.length < 4 || filters.length < 2 || lfos.length < 2 || !mixer || !rev || !del) return false;
|
||||
const revDecay = rev.params.decay ?? 2;
|
||||
const delFeedback = del.params.feedback ?? 0.4;
|
||||
return nonOutput.length >= 12 &&
|
||||
revDecay >= 3 && delFeedback >= 0.4 &&
|
||||
conns.length >= 15;
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
553
packages/client/src/game/levels/world9.js
Normal file
553
packages/client/src/game/levels/world9.js
Normal file
@@ -0,0 +1,553 @@
|
||||
/**
|
||||
* World 9 — "Síntesis Sustractiva Clásica" (Classic Subtractive Synthesis)
|
||||
*
|
||||
* Teaches: Moog-style synthesis, resonant filters, acid bass, PWM simulation
|
||||
* 8 levels, boss challenges with complete subtractive synth
|
||||
*/
|
||||
|
||||
export const WORLD_9 = {
|
||||
id: 'w9',
|
||||
name: 'Síntesis Sustractiva',
|
||||
subtitle: 'Los sonidos clásicos del sintetizador',
|
||||
icon: '▽~',
|
||||
color: '#ff4466',
|
||||
unlockStars: 96,
|
||||
levels: [
|
||||
// ─────────────── LEVEL 9.1 ───────────────
|
||||
{
|
||||
id: 'w9-1',
|
||||
title: 'Lead Sawtooth',
|
||||
subtitle: 'La onda más rica en armónicos',
|
||||
description: 'El sawtooth es la onda fundamental de la síntesis sustractiva — contiene todos los armónicos. Conecta un oscilador sawtooth a un filtro lowpass para quitar brillo, y un VCA para controlar el volumen.',
|
||||
concept: 'Osc sawtooth → Filter LP → VCA → Output. El filtro controla el brillo, el VCA controla la amplitud. Ajusta la frecuencia y el cutoff del filtro para explorar sonidos.',
|
||||
availableModules: ['oscillator', 'filter', 'vca', 'envelope', 'keyboard'],
|
||||
preplacedModules: [
|
||||
{ id: 1, type: 'output', x: 700, y: 120, params: { volume: -6 }, locked: true },
|
||||
],
|
||||
target: {
|
||||
build: [
|
||||
{ type: 'oscillator', params: { waveform: 'sawtooth', frequency: 220, detune: 0 } },
|
||||
],
|
||||
filter: { type: 'lowpass', frequency: 4000, Q: 1.2 },
|
||||
envelope: { attack: 0.05, decay: 0.3, sustain: 0.4, release: 0.2 },
|
||||
duration: 3,
|
||||
},
|
||||
checks: [
|
||||
{
|
||||
star: 1,
|
||||
name: 'Sawtooth básico',
|
||||
desc: 'Osc sawtooth → Filter → VCA → Output',
|
||||
test: (mods, conns) => {
|
||||
const osc = mods.find(m => m.type === 'oscillator');
|
||||
const flt = mods.find(m => m.type === 'filter');
|
||||
const vca = mods.find(m => m.type === 'vca');
|
||||
const out = mods.find(m => m.type === 'output');
|
||||
if (!osc || !flt || !vca || !out) return false;
|
||||
return osc.params.waveform === 'sawtooth' &&
|
||||
conns.some(c => c.from.moduleId === osc.id && c.to.moduleId === flt.id);
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 2,
|
||||
name: 'Filtro activo',
|
||||
desc: 'Filtro lowpass con cutoff controlable',
|
||||
test: (mods, conns) => {
|
||||
const flt = mods.find(m => m.type === 'filter');
|
||||
if (!flt) return false;
|
||||
return flt.params.type === 'lowpass' &&
|
||||
(flt.params.frequency ?? 1000) > 500 &&
|
||||
(flt.params.Q ?? 1) >= 1;
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 3,
|
||||
name: 'Lead completo',
|
||||
desc: 'Sawtooth + LP + VCA + envelope + keyboard',
|
||||
test: (mods, conns) => {
|
||||
const osc = mods.find(m => m.type === 'oscillator');
|
||||
const flt = mods.find(m => m.type === 'filter');
|
||||
const vca = mods.find(m => m.type === 'vca');
|
||||
const env = mods.find(m => m.type === 'envelope');
|
||||
const kb = mods.find(m => m.type === 'keyboard');
|
||||
if (!osc || !flt || !vca || !env || !kb) return false;
|
||||
return osc.params.waveform === 'sawtooth' &&
|
||||
flt.params.type === 'lowpass' &&
|
||||
conns.some(c => c.from.moduleId === kb.id && c.to.moduleId === env.id && c.to.port === 'gate') &&
|
||||
conns.some(c => c.from.moduleId === env.id && c.to.moduleId === vca.id && c.to.port === 'cv');
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// ─────────────── LEVEL 9.2 ───────────────
|
||||
{
|
||||
id: 'w9-2',
|
||||
title: 'Filtro Resonante',
|
||||
subtitle: 'El corazón de Moog',
|
||||
description: 'La resonancia (Q alto) en el filtro crea un pico característico en el cutoff frequency. Este es el sonido Moog: cuando bajas el cutoff con resonancia, el filtro empieza a auto-oscilar y cantar.',
|
||||
concept: 'Osc sawtooth → Filter LP (Q > 4) → VCA → Output. Cuanto más alto el Q, más dramático el efecto. Baja el cutoff lentamente para escuchar la resonancia.',
|
||||
availableModules: ['oscillator', 'filter', 'vca', 'lfo', 'envelope', 'keyboard'],
|
||||
preplacedModules: [
|
||||
{ id: 1, type: 'output', x: 700, y: 120, params: { volume: -6 }, locked: true },
|
||||
],
|
||||
target: {
|
||||
build: [
|
||||
{ type: 'oscillator', params: { waveform: 'sawtooth', frequency: 165, detune: 0 } },
|
||||
],
|
||||
filter: { type: 'lowpass', frequency: 2500, Q: 6 },
|
||||
lfo: { frequency: 0.8, type: 'sine', min: 1000, max: 4500, target: 'frequency' },
|
||||
envelope: { attack: 0.08, decay: 0.4, sustain: 0.3, release: 0.25 },
|
||||
duration: 3,
|
||||
},
|
||||
checks: [
|
||||
{
|
||||
star: 1,
|
||||
name: 'Resonancia perceptible',
|
||||
desc: 'Filtro LP con Q > 3',
|
||||
test: (mods) => {
|
||||
const flt = mods.find(m => m.type === 'filter');
|
||||
if (!flt) return false;
|
||||
return flt.params.type === 'lowpass' &&
|
||||
(flt.params.Q ?? 1) > 3;
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 2,
|
||||
name: 'Moog Resonante',
|
||||
desc: 'Sawtooth + LP (Q > 5) + VCA + envelope',
|
||||
test: (mods) => {
|
||||
const osc = mods.find(m => m.type === 'oscillator');
|
||||
const flt = mods.find(m => m.type === 'filter');
|
||||
const vca = mods.find(m => m.type === 'vca');
|
||||
const env = mods.find(m => m.type === 'envelope');
|
||||
if (!osc || !flt || !vca || !env) return false;
|
||||
return osc.params.waveform === 'sawtooth' &&
|
||||
flt.params.type === 'lowpass' &&
|
||||
(flt.params.Q ?? 1) > 5 &&
|
||||
(env.params.attack ?? 0.01) < 0.1;
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 3,
|
||||
name: 'Barrido de Filtro',
|
||||
desc: 'LFO modulando el cutoff del filtro con resonancia alta',
|
||||
test: (mods, conns) => {
|
||||
const flt = mods.find(m => m.type === 'filter');
|
||||
const lfo = mods.find(m => m.type === 'lfo');
|
||||
if (!flt || !lfo) return false;
|
||||
return flt.params.type === 'lowpass' &&
|
||||
(flt.params.Q ?? 1) > 4 &&
|
||||
conns.some(c => c.from.moduleId === lfo.id && c.to.moduleId === flt.id && c.to.port === 'cutoff');
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// ─────────────── LEVEL 9.3 ───────────────
|
||||
{
|
||||
id: 'w9-3',
|
||||
title: 'Brass Stab',
|
||||
subtitle: 'El ataque metálico',
|
||||
description: 'Un "brass stab" es un sonido de trompeta: square wave, filtro que se abre rápido en el ataque y luego se cierra. El envelope en el filtro crea el efecto de "toque" de la trompeta.',
|
||||
concept: 'Osc square → Filter LP → VCA → Output. El truco: el envelope NO va al VCA sino al CUTOFF del filtro. Attack del env muy corto. El filtro sube y baja, no el volumen.',
|
||||
availableModules: ['oscillator', 'filter', 'vca', 'envelope', 'keyboard'],
|
||||
preplacedModules: [
|
||||
{ id: 1, type: 'output', x: 700, y: 120, params: { volume: -8 }, locked: true },
|
||||
],
|
||||
target: {
|
||||
build: [
|
||||
{ type: 'oscillator', params: { waveform: 'square', frequency: 330, detune: 0 } },
|
||||
],
|
||||
filter: { type: 'lowpass', frequency: 1800, Q: 2 },
|
||||
envelope: { attack: 0.01, decay: 0.35, sustain: 0.1, release: 0.15 },
|
||||
duration: 3,
|
||||
},
|
||||
checks: [
|
||||
{
|
||||
star: 1,
|
||||
name: 'Square + Filtro',
|
||||
desc: 'Osc square → Filter → VCA → Output',
|
||||
test: (mods, conns) => {
|
||||
const osc = mods.find(m => m.type === 'oscillator');
|
||||
const flt = mods.find(m => m.type === 'filter');
|
||||
const vca = mods.find(m => m.type === 'vca');
|
||||
if (!osc || !flt || !vca) return false;
|
||||
return osc.params.waveform === 'square' &&
|
||||
flt.params.type === 'lowpass' &&
|
||||
conns.some(c => c.from.moduleId === osc.id && c.to.moduleId === flt.id);
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 2,
|
||||
name: 'Envelope al Filtro',
|
||||
desc: 'Envelope conectado al cutoff del filtro',
|
||||
test: (mods, conns) => {
|
||||
const flt = mods.find(m => m.type === 'filter');
|
||||
const env = mods.find(m => m.type === 'envelope');
|
||||
if (!flt || !env) return false;
|
||||
return conns.some(c => c.from.moduleId === env.id && c.to.moduleId === flt.id && c.to.port === 'cutoff');
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 3,
|
||||
name: 'Brass Stab Perfecta',
|
||||
desc: 'Square + LP, envelope (attack < 0.02s) al cutoff, keyboard gatea el env',
|
||||
test: (mods, conns) => {
|
||||
const osc = mods.find(m => m.type === 'oscillator');
|
||||
const flt = mods.find(m => m.type === 'filter');
|
||||
const env = mods.find(m => m.type === 'envelope');
|
||||
const kb = mods.find(m => m.type === 'keyboard');
|
||||
if (!osc || !flt || !env || !kb) return false;
|
||||
return osc.params.waveform === 'square' &&
|
||||
flt.params.type === 'lowpass' &&
|
||||
(env.params.attack ?? 0.01) < 0.02 &&
|
||||
conns.some(c => c.from.moduleId === env.id && c.to.moduleId === flt.id && c.to.port === 'cutoff') &&
|
||||
conns.some(c => c.from.moduleId === kb.id && c.to.moduleId === env.id && c.to.port === 'gate');
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// ─────────────── LEVEL 9.4 ───────────────
|
||||
{
|
||||
id: 'w9-4',
|
||||
title: 'Acid Bass 303',
|
||||
subtitle: 'El sonido de la danza',
|
||||
description: 'El acid bass es el legendario sonido del sintetizador TB-303: oscilador a frecuencia grave, filtro lowpass muy resonante, y un envelope que modula el cutoff para crear el "slide" característico.',
|
||||
concept: 'Osc sawtooth/square ~55 Hz → Sequencer freq. Filter LP (Q muy alto, ~8+) → VCA → Output. Envelope rápido al cutoff. El sequencer proporciona las notas; el filtro hace el sonido "acid".',
|
||||
availableModules: ['oscillator', 'filter', 'vca', 'envelope', 'sequencer', 'keyboard'],
|
||||
preplacedModules: [
|
||||
{ id: 1, type: 'output', x: 700, y: 120, params: { volume: -6 }, locked: true },
|
||||
],
|
||||
target: {
|
||||
build: [
|
||||
{ type: 'oscillator', params: { waveform: 'sawtooth', frequency: 55, detune: 0 } },
|
||||
],
|
||||
filter: { type: 'lowpass', frequency: 800, Q: 9 },
|
||||
envelope: { attack: 0.02, decay: 0.25, sustain: 0.05, release: 0.15 },
|
||||
duration: 3,
|
||||
},
|
||||
checks: [
|
||||
{
|
||||
star: 1,
|
||||
name: 'Bajo + Secuenciador',
|
||||
desc: 'Sequencer → Osc grave + Filter → Output',
|
||||
test: (mods, conns) => {
|
||||
const seq = mods.find(m => m.type === 'sequencer');
|
||||
const osc = mods.find(m => m.type === 'oscillator');
|
||||
const flt = mods.find(m => m.type === 'filter');
|
||||
if (!seq || !osc || !flt) return false;
|
||||
return (osc.params.frequency ?? 440) < 100 &&
|
||||
conns.some(c => c.from.moduleId === seq.id && c.to.moduleId === osc.id);
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 2,
|
||||
name: 'Resonancia acid',
|
||||
desc: 'Filtro LP con Q > 6, envelope al cutoff',
|
||||
test: (mods, conns) => {
|
||||
const flt = mods.find(m => m.type === 'filter');
|
||||
const env = mods.find(m => m.type === 'envelope');
|
||||
if (!flt || !env) return false;
|
||||
return flt.params.type === 'lowpass' &&
|
||||
(flt.params.Q ?? 1) > 6 &&
|
||||
conns.some(c => c.from.moduleId === env.id && c.to.moduleId === flt.id && c.to.port === 'cutoff');
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 3,
|
||||
name: '303 Clásico',
|
||||
desc: 'Sequencer + osc < 60 Hz + LP (Q > 8) + envelope rápido al cutoff',
|
||||
test: (mods, conns) => {
|
||||
const seq = mods.find(m => m.type === 'sequencer');
|
||||
const osc = mods.find(m => m.type === 'oscillator');
|
||||
const flt = mods.find(m => m.type === 'filter');
|
||||
const env = mods.find(m => m.type === 'envelope');
|
||||
if (!seq || !osc || !flt || !env) return false;
|
||||
return (osc.params.frequency ?? 440) < 60 &&
|
||||
flt.params.type === 'lowpass' &&
|
||||
(flt.params.Q ?? 1) > 8 &&
|
||||
(env.params.decay ?? 0.2) < 0.3 &&
|
||||
conns.some(c => c.from.moduleId === seq.id && c.to.moduleId === osc.id);
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// ─────────────── LEVEL 9.5 ───────────────
|
||||
{
|
||||
id: 'w9-5',
|
||||
title: 'String Pad Detuned',
|
||||
subtitle: 'Capas de sierras',
|
||||
description: 'Los string pads de las sinfonías electrónicas usan múltiples osciladores ligeramente detuned, un filtro suave, y un envelope lento. El detune crea una "chorusing" natural que emula el sonido de múltiples instrumentos.',
|
||||
concept: '3 oscs sawtooth, cada uno con detune diferente (~0, +5, -7) → Mixer → Filter LP suave → VCA → Output. Envelope lento al VCA. Juntos crean una textura cálida y movible.',
|
||||
availableModules: ['oscillator', 'mixer', 'filter', 'vca', 'envelope', 'keyboard'],
|
||||
preplacedModules: [
|
||||
{ id: 1, type: 'output', x: 800, y: 120, params: { volume: -8 }, locked: true },
|
||||
],
|
||||
target: {
|
||||
build: [
|
||||
{ type: 'oscillator', params: { waveform: 'sawtooth', frequency: 110, detune: 0 } },
|
||||
{ type: 'oscillator', params: { waveform: 'sawtooth', frequency: 110, detune: 5 } },
|
||||
{ type: 'oscillator', params: { waveform: 'sawtooth', frequency: 110, detune: -7 } },
|
||||
],
|
||||
filter: { type: 'lowpass', frequency: 3500, Q: 0.9 },
|
||||
envelope: { attack: 0.08, decay: 0.8, sustain: 0.5, release: 0.4 },
|
||||
duration: 4,
|
||||
},
|
||||
checks: [
|
||||
{
|
||||
star: 1,
|
||||
name: 'Múltiples sierras',
|
||||
desc: '3 osciladores sawtooth → Mixer → Output',
|
||||
test: (mods, conns) => {
|
||||
const oscs = mods.filter(m => m.type === 'oscillator');
|
||||
const mixer = mods.find(m => m.type === 'mixer');
|
||||
if (oscs.length < 3 || !mixer) return false;
|
||||
return oscs.every(o => o.params.waveform === 'sawtooth') &&
|
||||
oscs.some(o => conns.some(c => c.from.moduleId === o.id && c.to.moduleId === mixer.id));
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 2,
|
||||
name: 'Detune activo',
|
||||
desc: 'Al menos 2 osciladores con detune diferente (|diff| > 3)',
|
||||
test: (mods) => {
|
||||
const oscs = mods.filter(m => m.type === 'oscillator' && m.params.waveform === 'sawtooth');
|
||||
if (oscs.length < 3) return false;
|
||||
const detunes = oscs.map(o => o.params.detune ?? 0);
|
||||
const maxDiff = Math.max(...detunes) - Math.min(...detunes);
|
||||
return maxDiff > 3;
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 3,
|
||||
name: 'String Pad Completa',
|
||||
desc: '3 saws detuned + mixer + LP + envelope lento al VCA',
|
||||
test: (mods, conns) => {
|
||||
const oscs = mods.filter(m => m.type === 'oscillator' && m.params.waveform === 'sawtooth');
|
||||
const mixer = mods.find(m => m.type === 'mixer');
|
||||
const flt = mods.find(m => m.type === 'filter');
|
||||
const vca = mods.find(m => m.type === 'vca');
|
||||
const env = mods.find(m => m.type === 'envelope');
|
||||
if (oscs.length < 3 || !mixer || !flt || !vca || !env) return false;
|
||||
const detunes = oscs.map(o => o.params.detune ?? 0);
|
||||
const maxDiff = Math.max(...detunes) - Math.min(...detunes);
|
||||
return maxDiff > 3 &&
|
||||
flt.params.type === 'lowpass' &&
|
||||
(env.params.attack ?? 0.01) < 0.1 &&
|
||||
(env.params.decay ?? 0.2) > 0.5 &&
|
||||
conns.some(c => c.from.moduleId === env.id && c.to.moduleId === vca.id && c.to.port === 'cv');
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// ─────────────── LEVEL 9.6 ───────────────
|
||||
{
|
||||
id: 'w9-6',
|
||||
title: 'PWM Simulator',
|
||||
subtitle: 'Pseudo Pulse Width Modulation',
|
||||
description: 'El PWM (Pulse Width Modulation) es cuando varías el ancho del pulso de una onda square. Podemos simularla mezclando dos osciladores square ligeramente detuned — crean una "beating" que suena como PWM.',
|
||||
concept: '2 oscs square, uno a frecuencia base, otro detuned ~3-5 cents → Mixer → Filter → VCA → Output. El beating de frecuencias crea la ilusión de PWM. Un LFO puede modular más aún.',
|
||||
availableModules: ['oscillator', 'mixer', 'filter', 'vca', 'lfo', 'envelope', 'keyboard'],
|
||||
preplacedModules: [
|
||||
{ id: 1, type: 'output', x: 800, y: 120, params: { volume: -8 }, locked: true },
|
||||
],
|
||||
target: {
|
||||
build: [
|
||||
{ type: 'oscillator', params: { waveform: 'square', frequency: 220, detune: 0 } },
|
||||
{ type: 'oscillator', params: { waveform: 'square', frequency: 220, detune: 4 } },
|
||||
],
|
||||
filter: { type: 'lowpass', frequency: 3000, Q: 1.5 },
|
||||
lfo: { frequency: 0.6, type: 'sine', min: 2500, max: 4500, target: 'frequency' },
|
||||
envelope: { attack: 0.06, decay: 0.35, sustain: 0.35, release: 0.2 },
|
||||
duration: 3,
|
||||
},
|
||||
checks: [
|
||||
{
|
||||
star: 1,
|
||||
name: 'Dos squares',
|
||||
desc: '2 osciladores square → Mixer → Output',
|
||||
test: (mods, conns) => {
|
||||
const oscs = mods.filter(m => m.type === 'oscillator' && m.params.waveform === 'square');
|
||||
const mixer = mods.find(m => m.type === 'mixer');
|
||||
if (oscs.length < 2 || !mixer) return false;
|
||||
return oscs.some(o => conns.some(c => c.from.moduleId === o.id && c.to.moduleId === mixer.id));
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 2,
|
||||
name: 'Beating audible',
|
||||
desc: 'Detune entre squares > 2 cents para audible beating',
|
||||
test: (mods) => {
|
||||
const oscs = mods.filter(m => m.type === 'oscillator' && m.params.waveform === 'square');
|
||||
if (oscs.length < 2) return false;
|
||||
const detunes = oscs.map(o => o.params.detune ?? 0);
|
||||
return Math.abs(detunes[0] - detunes[1]) > 2;
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 3,
|
||||
name: 'PWM Dinámico',
|
||||
desc: '2 squares detuned + mixer + filter + LFO al detune de un osc',
|
||||
test: (mods, conns) => {
|
||||
const oscs = mods.filter(m => m.type === 'oscillator' && m.params.waveform === 'square');
|
||||
const mixer = mods.find(m => m.type === 'mixer');
|
||||
const lfo = mods.find(m => m.type === 'lfo');
|
||||
const flt = mods.find(m => m.type === 'filter');
|
||||
if (oscs.length < 2 || !mixer || !lfo || !flt) return false;
|
||||
const detunes = oscs.map(o => o.params.detune ?? 0);
|
||||
const hasDetune = Math.abs(detunes[0] - detunes[1]) > 2;
|
||||
const lfoToOsc = oscs.some(o =>
|
||||
conns.some(c => c.from.moduleId === lfo.id && c.to.moduleId === o.id && c.to.port === 'detune')
|
||||
);
|
||||
return hasDetune && lfoToOsc && flt.params.type === 'lowpass';
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// ─────────────── LEVEL 9.7 ───────────────
|
||||
{
|
||||
id: 'w9-7',
|
||||
title: 'Filter Sweep Técnica',
|
||||
subtitle: 'Control dinámico del timbre',
|
||||
description: 'El filter sweep es el corazón de la síntesis sustractiva: modular la frecuencia de cutoff con un LFO o envelope. Esto cambia el timbre del sonido en tiempo real. Es la vida de la síntesis.',
|
||||
concept: 'Osc sawtooth → Filter LP → VCA → Output. LFO (frecuencia baja ~0.2-2 Hz) → Cutoff del filter. También conecta envelope al cutoff para un sweep más rápido. Keyboard dispara ambos.',
|
||||
availableModules: ['oscillator', 'filter', 'vca', 'lfo', 'envelope', 'keyboard'],
|
||||
preplacedModules: [
|
||||
{ id: 1, type: 'output', x: 700, y: 120, params: { volume: -8 }, locked: true },
|
||||
],
|
||||
target: {
|
||||
build: [
|
||||
{ type: 'oscillator', params: { waveform: 'sawtooth', frequency: 130, detune: 0 } },
|
||||
],
|
||||
filter: { type: 'lowpass', frequency: 2000, Q: 2 },
|
||||
lfo: { frequency: 1, type: 'sine', min: 500, max: 5000, target: 'frequency' },
|
||||
envelope: { attack: 0.07, decay: 0.5, sustain: 0.2, release: 0.25 },
|
||||
duration: 4,
|
||||
},
|
||||
checks: [
|
||||
{
|
||||
star: 1,
|
||||
name: 'LFO al Cutoff',
|
||||
desc: 'LFO conectado a cutoff del filtro',
|
||||
test: (mods, conns) => {
|
||||
const lfo = mods.find(m => m.type === 'lfo');
|
||||
const flt = mods.find(m => m.type === 'filter');
|
||||
if (!lfo || !flt) return false;
|
||||
return conns.some(c => c.from.moduleId === lfo.id && c.to.moduleId === flt.id && c.to.port === 'cutoff');
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 2,
|
||||
name: 'LFO lento',
|
||||
desc: 'LFO con frecuencia < 2 Hz para sweep audible',
|
||||
test: (mods) => {
|
||||
const lfo = mods.find(m => m.type === 'lfo');
|
||||
if (!lfo) return false;
|
||||
return (lfo.params.frequency ?? 2) < 2 &&
|
||||
(lfo.params.amplitude ?? 0.5) > 0.3;
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 3,
|
||||
name: 'Sweep Completo',
|
||||
desc: 'Sawtooth + LP + LFO lento + envelope al cutoff',
|
||||
test: (mods, conns) => {
|
||||
const osc = mods.find(m => m.type === 'oscillator');
|
||||
const flt = mods.find(m => m.type === 'filter');
|
||||
const lfo = mods.find(m => m.type === 'lfo');
|
||||
const env = mods.find(m => m.type === 'envelope');
|
||||
if (!osc || !flt || !lfo || !env) return false;
|
||||
return osc.params.waveform === 'sawtooth' &&
|
||||
flt.params.type === 'lowpass' &&
|
||||
(lfo.params.frequency ?? 2) < 2 &&
|
||||
conns.some(c => c.from.moduleId === lfo.id && c.to.moduleId === flt.id && c.to.port === 'cutoff') &&
|
||||
conns.some(c => c.from.moduleId === env.id && c.to.moduleId === flt.id && c.to.port === 'cutoff');
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// ─────────────── LEVEL 9.8: BOSS ───────────────
|
||||
{
|
||||
id: 'w9-8',
|
||||
title: 'Sintetizador Clásico',
|
||||
subtitle: 'BOSS FINAL: Moog Completo',
|
||||
description: 'Construye el sintetizador sustractivo completo: múltiples osciladores, filtro resonante, envelopes, LFO, y todo conectado para crear sonidos ricos y expressivos. Este es el verdadero sintetizador analógico.',
|
||||
concept: 'Construye un synth con: 2+ osciladores (mezcla de saw/square), filtro LP resonante (Q > 4), 2+ envelopes, 1+ LFO, VCA, keyboard, y al menos un efecto. Todo debe sonar cohesivo y expressivo.',
|
||||
availableModules: ['oscillator', 'filter', 'vca', 'lfo', 'envelope', 'mixer', 'keyboard', 'delay', 'distortion', 'reverb'],
|
||||
preplacedModules: [
|
||||
{ id: 1, type: 'output', x: 900, y: 140, params: { volume: -8 }, locked: true },
|
||||
],
|
||||
target: {
|
||||
build: [
|
||||
{ type: 'oscillator', params: { waveform: 'sawtooth', frequency: 110, detune: 0 } },
|
||||
{ type: 'oscillator', params: { waveform: 'square', frequency: 110, detune: 3 } },
|
||||
],
|
||||
filter: { type: 'lowpass', frequency: 3000, Q: 6 },
|
||||
lfo: { frequency: 0.5, type: 'sine', min: 1000, max: 4000, target: 'frequency' },
|
||||
effects: [
|
||||
{ type: 'reverb', decay: 2.5, wet: 0.4 },
|
||||
],
|
||||
envelope: { attack: 0.08, decay: 0.5, sustain: 0.3, release: 0.3 },
|
||||
duration: 5,
|
||||
},
|
||||
checks: [
|
||||
{
|
||||
star: 1,
|
||||
name: 'Síntesis funcional',
|
||||
desc: 'Múltiples oscs + filtro LP + VCA + envelope + keyboard',
|
||||
test: (mods, conns) => {
|
||||
const oscs = mods.filter(m => m.type === 'oscillator');
|
||||
const flt = mods.find(m => m.type === 'filter');
|
||||
const vca = mods.find(m => m.type === 'vca');
|
||||
const env = mods.find(m => m.type === 'envelope');
|
||||
const kb = mods.find(m => m.type === 'keyboard');
|
||||
const out = mods.find(m => m.type === 'output');
|
||||
if (oscs.length < 2 || !flt || !vca || !env || !kb || !out) return false;
|
||||
return flt.params.type === 'lowpass' &&
|
||||
conns.some(c => c.to.moduleId === out.id);
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 2,
|
||||
name: 'Moog característico',
|
||||
desc: '2+ oscs + filtro LP resonante (Q > 4) + envelope modulando cutoff',
|
||||
test: (mods, conns) => {
|
||||
const oscs = mods.filter(m => m.type === 'oscillator');
|
||||
const flt = mods.find(m => m.type === 'filter');
|
||||
const env = mods.find(m => m.type === 'envelope');
|
||||
if (oscs.length < 2 || !flt || !env) return false;
|
||||
return flt.params.type === 'lowpass' &&
|
||||
(flt.params.Q ?? 1) > 4 &&
|
||||
conns.some(c => c.from.moduleId === env.id && c.to.moduleId === flt.id && c.to.port === 'cutoff');
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 3,
|
||||
name: 'Maestro Sustractivo',
|
||||
desc: '2+ oscs detuned + LP (Q > 5) + 2 envs + LFO + efecto + keyboard',
|
||||
test: (mods, conns) => {
|
||||
const oscs = mods.filter(m => m.type === 'oscillator');
|
||||
const flt = mods.find(m => m.type === 'filter');
|
||||
const envs = mods.filter(m => m.type === 'envelope');
|
||||
const lfo = mods.find(m => m.type === 'lfo');
|
||||
const kb = mods.find(m => m.type === 'keyboard');
|
||||
const effects = mods.filter(m => ['delay', 'reverb', 'distortion'].includes(m.type));
|
||||
if (oscs.length < 2 || !flt || envs.length < 2 || !lfo || !kb || effects.length < 1) return false;
|
||||
const detunes = oscs.map(o => o.params.detune ?? 0);
|
||||
const hasDetune = Math.max(...detunes) - Math.min(...detunes) > 2;
|
||||
return flt.params.type === 'lowpass' &&
|
||||
(flt.params.Q ?? 1) > 5 &&
|
||||
hasDetune &&
|
||||
conns.some(c => c.from.moduleId === lfo.id && c.to.moduleId === flt.id && c.to.port === 'cutoff') &&
|
||||
conns.length >= 12;
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
193
packages/client/src/game/targetAudio.js
Normal file
193
packages/client/src/game/targetAudio.js
Normal file
@@ -0,0 +1,193 @@
|
||||
/**
|
||||
* targetAudio.js — Plays the "target" sound for a puzzle level
|
||||
* Builds a temporary Tone.js graph from the level's target config
|
||||
*
|
||||
* Extended to support:
|
||||
* - Envelopes (amplitude shaping)
|
||||
* - LFO (modulation)
|
||||
* - Effects (delay, reverb, distortion)
|
||||
*/
|
||||
import * as Tone from 'tone';
|
||||
|
||||
let _activeNodes = [];
|
||||
let _isPlaying = false;
|
||||
let _stopTimeout = null;
|
||||
let _loops = []; // Track Tone.Loop instances for cleanup
|
||||
|
||||
export function isTargetPlaying() {
|
||||
return _isPlaying;
|
||||
}
|
||||
|
||||
export async function playTarget(target) {
|
||||
if (_isPlaying) {
|
||||
stopTarget();
|
||||
return;
|
||||
}
|
||||
|
||||
await Tone.start();
|
||||
_isPlaying = true;
|
||||
|
||||
const nodes = [];
|
||||
const output = new Tone.Gain(0.5).toDestination();
|
||||
nodes.push(output);
|
||||
|
||||
// Build effects chain (will connect to this)
|
||||
let effectChain = output;
|
||||
|
||||
// Effects array (in order: distortion → delay → reverb)
|
||||
if (target.effects && target.effects.length > 0) {
|
||||
const effectNodes = [];
|
||||
for (const effect of target.effects) {
|
||||
if (effect.type === 'distortion') {
|
||||
const distortion = new Tone.Distortion(effect.amount ?? 0.4);
|
||||
effectNodes.push(distortion);
|
||||
} else if (effect.type === 'delay') {
|
||||
const delay = new Tone.Delay(effect.time ?? 0.3);
|
||||
delay.feedback.value = effect.feedback ?? 0.3;
|
||||
delay.wet.value = effect.wet ?? 0.5;
|
||||
effectNodes.push(delay);
|
||||
} else if (effect.type === 'reverb') {
|
||||
const reverb = new Tone.Reverb(effect.decay ?? 2.5);
|
||||
reverb.wet.value = effect.wet ?? 0.5;
|
||||
effectNodes.push(reverb);
|
||||
}
|
||||
}
|
||||
|
||||
// Chain effects together, then to output
|
||||
if (effectNodes.length > 0) {
|
||||
for (let i = 0; i < effectNodes.length - 1; i++) {
|
||||
effectNodes[i].connect(effectNodes[i + 1]);
|
||||
}
|
||||
effectNodes[effectNodes.length - 1].connect(output);
|
||||
effectChain = effectNodes[0];
|
||||
nodes.push(...effectNodes);
|
||||
}
|
||||
}
|
||||
|
||||
// Optional filter in the chain
|
||||
let destination = effectChain;
|
||||
if (target.filter) {
|
||||
const filter = new Tone.Filter({
|
||||
type: target.filter.type || 'lowpass',
|
||||
frequency: target.filter.frequency || 1000,
|
||||
Q: target.filter.Q || 1,
|
||||
});
|
||||
filter.connect(effectChain);
|
||||
destination = filter;
|
||||
nodes.push(filter);
|
||||
}
|
||||
|
||||
// Optional envelope
|
||||
let envelope = null;
|
||||
if (target.envelope) {
|
||||
envelope = new Tone.AmplitudeEnvelope({
|
||||
attack: target.envelope.attack ?? 0.01,
|
||||
decay: target.envelope.decay ?? 0.1,
|
||||
sustain: target.envelope.sustain ?? 0.3,
|
||||
release: target.envelope.release ?? 0.5,
|
||||
});
|
||||
envelope.connect(destination);
|
||||
destination = envelope;
|
||||
nodes.push(envelope);
|
||||
}
|
||||
|
||||
// Optional LFO for modulation
|
||||
let lfo = null;
|
||||
if (target.lfo) {
|
||||
lfo = new Tone.LFO({
|
||||
frequency: target.lfo.frequency ?? 5,
|
||||
type: target.lfo.type ?? 'sine',
|
||||
min: target.lfo.min ?? 0.5,
|
||||
max: target.lfo.max ?? 1.5,
|
||||
});
|
||||
|
||||
// Route LFO to the specified target
|
||||
if (target.lfo.target === 'amplitude' && envelope) {
|
||||
lfo.connect(envelope.gain);
|
||||
} else if (target.lfo.target === 'frequency' && target.build.length > 0) {
|
||||
// LFO will be connected to oscillators below
|
||||
}
|
||||
|
||||
lfo.start();
|
||||
nodes.push(lfo);
|
||||
}
|
||||
|
||||
// Build oscillators / noise from target.build
|
||||
for (const spec of target.build) {
|
||||
if (spec.type === 'oscillator') {
|
||||
const osc = new Tone.Oscillator({
|
||||
type: spec.params.waveform || 'sine',
|
||||
frequency: spec.params.frequency || 440,
|
||||
detune: spec.params.detune || 0,
|
||||
});
|
||||
osc.connect(destination);
|
||||
|
||||
// Connect LFO to frequency if specified
|
||||
if (lfo && target.lfo?.target === 'frequency') {
|
||||
lfo.connect(osc.frequency);
|
||||
}
|
||||
|
||||
osc.start();
|
||||
nodes.push(osc);
|
||||
} else if (spec.type === 'noise') {
|
||||
const noise = new Tone.Noise(spec.params.type || 'white');
|
||||
noise.connect(destination);
|
||||
noise.start();
|
||||
nodes.push(noise);
|
||||
}
|
||||
}
|
||||
|
||||
// Handle envelope retriggering with triggerPattern
|
||||
if (envelope && target.triggerPattern) {
|
||||
const pattern = target.triggerPattern;
|
||||
const interval = pattern.interval ?? 0.5;
|
||||
const count = pattern.count ?? Math.ceil((target.duration || 2) / interval);
|
||||
|
||||
const loop = new Tone.Loop((time) => {
|
||||
envelope.triggerAttackRelease(
|
||||
target.envelope.attack + target.envelope.decay + target.envelope.release,
|
||||
time
|
||||
);
|
||||
}, interval);
|
||||
|
||||
loop.start(0);
|
||||
nodes.push(loop);
|
||||
_loops.push(loop);
|
||||
} else if (envelope) {
|
||||
// Single trigger if no pattern
|
||||
envelope.triggerAttack();
|
||||
}
|
||||
|
||||
_activeNodes = nodes;
|
||||
|
||||
// Auto-stop after duration
|
||||
const dur = (target.duration || 2) * 1000;
|
||||
_stopTimeout = setTimeout(() => stopTarget(), dur);
|
||||
}
|
||||
|
||||
export function stopTarget() {
|
||||
if (_stopTimeout) {
|
||||
clearTimeout(_stopTimeout);
|
||||
_stopTimeout = null;
|
||||
}
|
||||
|
||||
// Stop and cleanup loops
|
||||
for (const loop of _loops) {
|
||||
try {
|
||||
loop.stop();
|
||||
loop.dispose();
|
||||
} catch {}
|
||||
}
|
||||
_loops = [];
|
||||
|
||||
// Stop and cleanup nodes
|
||||
for (const node of _activeNodes) {
|
||||
try {
|
||||
if (node.stop) node.stop();
|
||||
if (node.disconnect) node.disconnect();
|
||||
if (node.dispose) node.dispose();
|
||||
} catch {}
|
||||
}
|
||||
_activeNodes = [];
|
||||
_isPlaying = false;
|
||||
}
|
||||
14
packages/client/src/hooks/useIsMobile.js
Normal file
14
packages/client/src/hooks/useIsMobile.js
Normal 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
packages/client/src/hooks/usePinchZoom.js
Normal file
48
packages/client/src/hooks/usePinchZoom.js
Normal 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]);
|
||||
}
|
||||
1165
packages/client/src/index.css
Normal file
1165
packages/client/src/index.css
Normal file
File diff suppressed because it is too large
Load Diff
37
packages/client/src/main.jsx
Normal file
37
packages/client/src/main.jsx
Normal file
@@ -0,0 +1,37 @@
|
||||
import React, { useState } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import App from './App';
|
||||
import GameApp from './game/GameApp.jsx';
|
||||
import './index.css';
|
||||
|
||||
function Root() {
|
||||
const [mode, setMode] = useState('game'); // 'game' | 'sandbox'
|
||||
|
||||
if (mode === 'sandbox') {
|
||||
return <App onSwitchToGame={() => setMode('game')} />;
|
||||
}
|
||||
|
||||
return <GameApp onSwitchToSandbox={() => setMode('sandbox')} />;
|
||||
}
|
||||
|
||||
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(() => {});
|
||||
});
|
||||
}
|
||||
78
packages/client/src/presets/chiptune.js
Normal file
78
packages/client/src/presets/chiptune.js
Normal file
@@ -0,0 +1,78 @@
|
||||
/**
|
||||
* Super Mario Bros Chiptune Demo Preset
|
||||
*
|
||||
* Signal flow:
|
||||
* Piano Roll → [freq] Osc1 (square, melody) → VCA1 ← Envelope1 (short staccato)
|
||||
* Piano Roll → [freq] Osc2 (square, sub octave) → VCA2 ← Envelope2 (bass body)
|
||||
* LFO (subtle vibrato) → Osc1 detune
|
||||
* VCA1 + VCA2 → Mixer → Filter (bright lowpass) → Delay (subtle echo) → Output
|
||||
* Mixer → Scope
|
||||
*
|
||||
* Tuned for NES-style square wave chiptune at ~200 BPM
|
||||
*/
|
||||
|
||||
export const CHIPTUNE_PRESET = {
|
||||
modules: [
|
||||
// Source: Piano Roll with Mario melody
|
||||
{ id: 1, type: 'pianoroll', x: 40, y: 40, params: { bpm: 200, loop: 'on', bars: '8' } },
|
||||
|
||||
// Oscillators: square waves for authentic NES sound
|
||||
{ id: 2, type: 'oscillator', x: 600, y: 20, params: { waveform: 'square', frequency: 440, detune: 0 } },
|
||||
{ id: 3, type: 'oscillator', x: 600, y: 260, params: { waveform: 'square', frequency: 220, detune: 0 } },
|
||||
|
||||
// Subtle vibrato LFO
|
||||
{ id: 4, type: 'lfo', x: 40, y: 380, params: { waveform: 'sine', frequency: 6, amplitude: 0.15 } },
|
||||
|
||||
// Envelopes: short and snappy for chiptune feel
|
||||
{ id: 5, type: 'envelope', x: 820, y: 20, params: { attack: 0.001, decay: 0.08, sustain: 0.6, release: 0.05 } },
|
||||
{ id: 6, type: 'envelope', x: 820, y: 240, params: { attack: 0.001, decay: 0.1, sustain: 0.4, release: 0.08 } },
|
||||
|
||||
// VCAs
|
||||
{ id: 7, type: 'vca', x: 1020, y: 20, params: { gain: 0.7 } },
|
||||
{ id: 8, type: 'vca', x: 1020, y: 220, params: { gain: 0.35 } },
|
||||
|
||||
// Mixer, processing, output
|
||||
{ id: 9, type: 'mixer', x: 1220, y: 60, params: { gain1: 0.8, gain2: 0.5, gain3: 0.0, gain4: 0.0 } },
|
||||
{ id: 10, type: 'filter', x: 1420, y: 40, params: { type: 'lowpass', frequency: 6000, Q: 1.5 } },
|
||||
{ id: 11, type: 'delay', x: 1620, y: 40, params: { delayTime: 0.15, feedback: 0.2, wet: 0.15 } },
|
||||
{ id: 13, type: 'output', x: 1840, y: 120, params: { volume: -8 } },
|
||||
|
||||
// Scope
|
||||
{ id: 14, type: 'scope', x: 1220, y: 320, params: {} },
|
||||
],
|
||||
connections: [
|
||||
// Piano Roll → Oscillators (freq)
|
||||
{ id: 1, from: { moduleId: 1, port: 'freq' }, to: { moduleId: 2, port: 'freq' } },
|
||||
{ id: 2, from: { moduleId: 1, port: 'freq' }, to: { moduleId: 3, port: 'freq' } },
|
||||
|
||||
// Piano Roll → Envelopes (gate)
|
||||
{ id: 3, from: { moduleId: 1, port: 'gate' }, to: { moduleId: 5, port: 'gate' } },
|
||||
{ id: 4, from: { moduleId: 1, port: 'gate' }, to: { moduleId: 6, port: 'gate' } },
|
||||
|
||||
// LFO → Osc1 detune (vibrato)
|
||||
{ id: 5, from: { moduleId: 4, port: 'out' }, to: { moduleId: 2, port: 'detune' } },
|
||||
|
||||
// Osc1 → VCA1, Envelope1 → VCA1 CV
|
||||
{ id: 6, from: { moduleId: 2, port: 'out' }, to: { moduleId: 7, port: 'in' } },
|
||||
{ id: 7, from: { moduleId: 5, port: 'out' }, to: { moduleId: 7, port: 'cv' } },
|
||||
|
||||
// Osc2 → VCA2, Envelope2 → VCA2 CV
|
||||
{ id: 8, from: { moduleId: 3, port: 'out' }, to: { moduleId: 8, port: 'in' } },
|
||||
{ id: 9, from: { moduleId: 6, port: 'out' }, to: { moduleId: 8, port: 'cv' } },
|
||||
|
||||
// VCA1 + VCA2 → Mixer
|
||||
{ id: 10, from: { moduleId: 7, port: 'out' }, to: { moduleId: 9, port: 'in1' } },
|
||||
{ id: 11, from: { moduleId: 8, port: 'out' }, to: { moduleId: 9, port: 'in2' } },
|
||||
|
||||
// Mixer → Filter → Delay → Output
|
||||
{ id: 12, from: { moduleId: 9, port: 'out' }, to: { moduleId: 10, port: 'in' } },
|
||||
{ id: 13, from: { moduleId: 10, port: 'out' }, to: { moduleId: 11, port: 'in' } },
|
||||
{ id: 15, from: { moduleId: 11, port: 'out' }, to: { moduleId: 13, port: 'left' } },
|
||||
{ id: 16, from: { moduleId: 11, port: 'out' }, to: { moduleId: 13, port: 'right' } },
|
||||
|
||||
// Mixer → Scope
|
||||
{ id: 17, from: { moduleId: 9, port: 'out' }, to: { moduleId: 14, port: 'in' } },
|
||||
],
|
||||
camera: { camX: 0, camY: 0, zoom: 1 },
|
||||
masterVolume: -8,
|
||||
};
|
||||
8
packages/client/src/utils/bezier.js
Normal file
8
packages/client/src/utils/bezier.js
Normal file
@@ -0,0 +1,8 @@
|
||||
/**
|
||||
* Generate SVG bezier path between two points (for wires)
|
||||
*/
|
||||
export function wirePath(x1, y1, x2, y2) {
|
||||
const dx = Math.abs(x2 - x1);
|
||||
const cp = Math.max(50, dx * 0.5);
|
||||
return `M ${x1} ${y1} C ${x1 + cp} ${y1}, ${x2 - cp} ${y2}, ${x2} ${y2}`;
|
||||
}
|
||||
130
packages/client/src/utils/midiParser.js
Normal file
130
packages/client/src/utils/midiParser.js
Normal file
@@ -0,0 +1,130 @@
|
||||
/**
|
||||
* midiParser.js — Minimal MIDI file parser for piano roll import
|
||||
* Extracts note events (note-on/off) from a standard MIDI file (.mid)
|
||||
*/
|
||||
|
||||
function readVarLen(data, offset) {
|
||||
let value = 0;
|
||||
let byte;
|
||||
do {
|
||||
byte = data[offset++];
|
||||
value = (value << 7) | (byte & 0x7f);
|
||||
} while (byte & 0x80);
|
||||
return { value, offset };
|
||||
}
|
||||
|
||||
function readUint16(data, offset) {
|
||||
return (data[offset] << 8) | data[offset + 1];
|
||||
}
|
||||
|
||||
function readUint32(data, offset) {
|
||||
return (data[offset] << 24) | (data[offset + 1] << 16) | (data[offset + 2] << 8) | data[offset + 3];
|
||||
}
|
||||
|
||||
export function parseMidi(arrayBuffer) {
|
||||
const data = new Uint8Array(arrayBuffer);
|
||||
let offset = 0;
|
||||
|
||||
// Read header chunk
|
||||
const headerTag = String.fromCharCode(...data.slice(0, 4));
|
||||
if (headerTag !== 'MThd') throw new Error('Not a MIDI file');
|
||||
offset = 4;
|
||||
const headerLen = readUint32(data, offset); offset += 4;
|
||||
const format = readUint16(data, offset); offset += 2;
|
||||
const numTracks = readUint16(data, offset); offset += 2;
|
||||
const ticksPerBeat = readUint16(data, offset); offset += 2;
|
||||
|
||||
// Parse all tracks, collect note events
|
||||
const allNotes = [];
|
||||
let tempo = 500000; // default 120 BPM in microseconds per beat
|
||||
|
||||
for (let t = 0; t < numTracks; t++) {
|
||||
const trackTag = String.fromCharCode(...data.slice(offset, offset + 4));
|
||||
if (trackTag !== 'MTrk') { offset += 8; continue; }
|
||||
offset += 4;
|
||||
const trackLen = readUint32(data, offset); offset += 4;
|
||||
const trackEnd = offset + trackLen;
|
||||
|
||||
let tick = 0;
|
||||
let runningStatus = 0;
|
||||
const activeNotes = {}; // midi note -> { tick, velocity }
|
||||
|
||||
while (offset < trackEnd) {
|
||||
const delta = readVarLen(data, offset);
|
||||
tick += delta.value;
|
||||
offset = delta.offset;
|
||||
|
||||
let status = data[offset];
|
||||
if (status & 0x80) {
|
||||
runningStatus = status;
|
||||
offset++;
|
||||
} else {
|
||||
status = runningStatus;
|
||||
}
|
||||
|
||||
const type = status & 0xf0;
|
||||
|
||||
if (type === 0x90) {
|
||||
// Note on
|
||||
const note = data[offset++];
|
||||
const velocity = data[offset++];
|
||||
if (velocity > 0) {
|
||||
activeNotes[note] = { tick, velocity };
|
||||
} else {
|
||||
// Note on with velocity 0 = note off
|
||||
if (activeNotes[note]) {
|
||||
const start = activeNotes[note].tick;
|
||||
allNotes.push({ note, startTick: start, endTick: tick });
|
||||
delete activeNotes[note];
|
||||
}
|
||||
}
|
||||
} else if (type === 0x80) {
|
||||
// Note off
|
||||
const note = data[offset++];
|
||||
offset++; // velocity (unused)
|
||||
if (activeNotes[note]) {
|
||||
const start = activeNotes[note].tick;
|
||||
allNotes.push({ note, startTick: start, endTick: tick });
|
||||
delete activeNotes[note];
|
||||
}
|
||||
} else if (type === 0xa0 || type === 0xb0 || type === 0xe0) {
|
||||
offset += 2; // 2-byte messages
|
||||
} else if (type === 0xc0 || type === 0xd0) {
|
||||
offset += 1; // 1-byte messages
|
||||
} else if (status === 0xff) {
|
||||
// Meta event
|
||||
const metaType = data[offset++];
|
||||
const len = readVarLen(data, offset);
|
||||
offset = len.offset;
|
||||
if (metaType === 0x51 && len.value === 3) {
|
||||
// Tempo change
|
||||
tempo = (data[offset] << 16) | (data[offset + 1] << 8) | data[offset + 2];
|
||||
}
|
||||
offset += len.value;
|
||||
} else if (status === 0xf0 || status === 0xf7) {
|
||||
// SysEx
|
||||
const len = readVarLen(data, offset);
|
||||
offset = len.offset + len.value;
|
||||
} else {
|
||||
// Unknown, skip
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
offset = trackEnd;
|
||||
}
|
||||
|
||||
// Convert ticks to beats
|
||||
const notes = allNotes.map(n => ({
|
||||
note: n.note,
|
||||
start: n.startTick / ticksPerBeat,
|
||||
duration: Math.max(0.25, (n.endTick - n.startTick) / ticksPerBeat),
|
||||
}));
|
||||
|
||||
// Sort by start time
|
||||
notes.sort((a, b) => a.start - b.start);
|
||||
|
||||
const bpm = Math.round(60000000 / tempo);
|
||||
|
||||
return { notes, bpm, ticksPerBeat };
|
||||
}
|
||||
8
packages/client/vite.config.js
Normal file
8
packages/client/vite.config.js
Normal file
@@ -0,0 +1,8 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: { port: 3000 },
|
||||
build: { outDir: '../../dist', emptyOutDir: true }
|
||||
});
|
||||
Reference in New Issue
Block a user