feat: modular synth, sandbox, code editor, pixel editor, Docker deployment
Synth: - Modular synth with Tone.js: oscillator, LFO, noise, filter, envelope, VCA, mixer, delay, reverb, distortion, output - Keyboard widget (mini SVG + fullscreen piano + computer keys Z-M/Q-I) - Drum pad (4x4 grid, 16 pads with MIDI notes, matching reaktor) - Sequencer (SVG bar grid with pitch/gate editing, matching reaktor) - Live modulation visualization (LFO waveform simulation, envelope, noise) - Knob with drag, wheel, double-click inline edit, modulation glow ring - Pan/zoom viewport, bezier wires colored by port type - Play/Stop audio lifecycle, stereo output with Tone.Merge Sandbox: - New /sandbox page with all editors in freeform mode - Synth fills full viewport height Workbenches: - Code Editor (Monaco) with test cases - Signal Playground (Web Audio oscillator + filter + visualizer) - Pixel Editor (grid canvas with palette and match mode) Deployment: - Dockerfile (multi-stage Next.js standalone build) - .dockerignore - next.config.ts output: standalone - Gitea remote configured Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
8
.dockerignore
Normal file
8
.dockerignore
Normal file
@@ -0,0 +1,8 @@
|
||||
node_modules
|
||||
.next
|
||||
.git
|
||||
*.zip
|
||||
todo.md
|
||||
idea.txt
|
||||
CLAUDE.md
|
||||
AGENTS.md
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -39,3 +39,4 @@ yarn-error.log*
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
project-math.zip
|
||||
|
||||
20
Dockerfile
Normal file
20
Dockerfile
Normal file
@@ -0,0 +1,20 @@
|
||||
# Stage 1: Build
|
||||
FROM node:20-alpine AS build
|
||||
WORKDIR /app
|
||||
COPY package.json package-lock.json* ./
|
||||
RUN npm ci --include=dev
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
# Stage 2: Production
|
||||
FROM node:20-alpine
|
||||
WORKDIR /app
|
||||
ENV NODE_ENV=production PORT=80
|
||||
|
||||
# Copy standalone output
|
||||
COPY --from=build /app/.next/standalone ./
|
||||
COPY --from=build /app/.next/static ./.next/static
|
||||
COPY --from=build /app/public ./public
|
||||
|
||||
EXPOSE 80
|
||||
CMD ["node", "server.js"]
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
/* config options here */
|
||||
output: "standalone",
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
|
||||
107
package-lock.json
generated
107
package-lock.json
generated
@@ -10,6 +10,7 @@
|
||||
"dependencies": {
|
||||
"@base-ui/react": "^1.3.0",
|
||||
"@dagrejs/dagre": "^3.0.0",
|
||||
"@monaco-editor/react": "^4.7.0",
|
||||
"@types/katex": "^0.16.8",
|
||||
"@xyflow/react": "^12.10.1",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
@@ -21,6 +22,7 @@
|
||||
"react-dom": "19.2.4",
|
||||
"shadcn": "^4.1.0",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
"tone": "^15.1.22",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"zustand": "^5.0.12"
|
||||
},
|
||||
@@ -1662,6 +1664,29 @@
|
||||
"integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@monaco-editor/loader": {
|
||||
"version": "1.7.0",
|
||||
"resolved": "https://registry.npmjs.org/@monaco-editor/loader/-/loader-1.7.0.tgz",
|
||||
"integrity": "sha512-gIwR1HrJrrx+vfyOhYmCZ0/JcWqG5kbfG7+d3f/C1LXk2EvzAbHSg3MQ5lO2sMlo9izoAZ04shohfKLVT6crVA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"state-local": "^1.0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/@monaco-editor/react": {
|
||||
"version": "4.7.0",
|
||||
"resolved": "https://registry.npmjs.org/@monaco-editor/react/-/react-4.7.0.tgz",
|
||||
"integrity": "sha512-cyzXQCtO47ydzxpQtCGSQGOC8Gk3ZUeBXFAxD+CWXYFo5OqZyZUonFl0DwUlTyAfRHntBfw2p3w4s9R6oe1eCA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@monaco-editor/loader": "^1.5.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"monaco-editor": ">= 0.25.0 < 1",
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@mswjs/interceptors": {
|
||||
"version": "0.41.3",
|
||||
"resolved": "https://registry.npmjs.org/@mswjs/interceptors/-/interceptors-0.41.3.tgz",
|
||||
@@ -2445,6 +2470,14 @@
|
||||
"integrity": "sha512-xMAgYwceFhRA2zY+XbEA7mxYbA093wdiW8Vu6gZPGWy9cmOyU9XesH1tNcEWsKFd5Vzrqx5T3D38PWx1FIIXkA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/trusted-types": {
|
||||
"version": "2.0.7",
|
||||
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
|
||||
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@types/validate-npm-package-name": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/validate-npm-package-name/-/validate-npm-package-name-4.0.2.tgz",
|
||||
@@ -3408,6 +3441,19 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/automation-events": {
|
||||
"version": "7.1.16",
|
||||
"resolved": "https://registry.npmjs.org/automation-events/-/automation-events-7.1.16.tgz",
|
||||
"integrity": "sha512-vAAHG8WO+Cx2PfwmWIAxSD51ZYg+zRam52pzOGVAJOqsqQO6oaPM2k4/cdEF7QQ786FYB8Wzbw//qTWCdyGvzA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.29.2",
|
||||
"tslib": "^2.8.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/available-typed-arrays": {
|
||||
"version": "1.0.7",
|
||||
"resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz",
|
||||
@@ -4303,6 +4349,16 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/dompurify": {
|
||||
"version": "3.2.7",
|
||||
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.2.7.tgz",
|
||||
"integrity": "sha512-WhL/YuveyGXJaerVlMYGWhvQswa7myDG17P7Vu65EWC05o8vfeNbvNf4d/BOvH99+ZW+LlQsc1GDKMa1vNK6dw==",
|
||||
"license": "(MPL-2.0 OR Apache-2.0)",
|
||||
"peer": true,
|
||||
"optionalDependencies": {
|
||||
"@types/trusted-types": "^2.0.7"
|
||||
}
|
||||
},
|
||||
"node_modules/dotenv": {
|
||||
"version": "17.3.1",
|
||||
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.3.1.tgz",
|
||||
@@ -7098,6 +7154,19 @@
|
||||
"@jridgewell/sourcemap-codec": "^1.5.5"
|
||||
}
|
||||
},
|
||||
"node_modules/marked": {
|
||||
"version": "14.0.0",
|
||||
"resolved": "https://registry.npmjs.org/marked/-/marked-14.0.0.tgz",
|
||||
"integrity": "sha512-uIj4+faQ+MgHgwUW1l2PsPglZLOLOT1uErt06dAPtx2kjteLAkbsd/0FiYg/MGS+i7ZKLb7w2WClxHkzOOuryQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"marked": "bin/marked.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 18"
|
||||
}
|
||||
},
|
||||
"node_modules/math-intrinsics": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
||||
@@ -7224,6 +7293,17 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/monaco-editor": {
|
||||
"version": "0.55.1",
|
||||
"resolved": "https://registry.npmjs.org/monaco-editor/-/monaco-editor-0.55.1.tgz",
|
||||
"integrity": "sha512-jz4x+TJNFHwHtwuV9vA9rMujcZRb0CEilTEwG2rRSpe/A7Jdkuj8xPKttCgOh+v/lkHy7HsZ64oj+q3xoAFl9A==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"dompurify": "3.2.7",
|
||||
"marked": "14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/ms": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
@@ -8853,6 +8933,23 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/standardized-audio-context": {
|
||||
"version": "25.3.77",
|
||||
"resolved": "https://registry.npmjs.org/standardized-audio-context/-/standardized-audio-context-25.3.77.tgz",
|
||||
"integrity": "sha512-Ki9zNz6pKcC5Pi+QPjPyVsD9GwJIJWgryji0XL9cAJXMGyn+dPOf6Qik1AHei0+UNVcc4BOCa0hWLBzlwqsW/A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.25.6",
|
||||
"automation-events": "^7.0.9",
|
||||
"tslib": "^2.7.0"
|
||||
}
|
||||
},
|
||||
"node_modules/state-local": {
|
||||
"version": "1.0.7",
|
||||
"resolved": "https://registry.npmjs.org/state-local/-/state-local-1.0.7.tgz",
|
||||
"integrity": "sha512-HTEHMNieakEnoe33shBYcZ7NX83ACUjCu8c40iOGEZsngj9zRnkqS9j1pqQPXwobB0ZcVTk27REb7COQ0UR59w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/statuses": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
|
||||
@@ -9287,6 +9384,16 @@
|
||||
"node": ">=0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/tone": {
|
||||
"version": "15.1.22",
|
||||
"resolved": "https://registry.npmjs.org/tone/-/tone-15.1.22.tgz",
|
||||
"integrity": "sha512-TCScAGD4sLsama5DjvTUXlLDXSqPealhL64nsdV1hhr6frPWve0DeSo63AKnSJwgfg55fhvxj0iPPRwPN5o0ag==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"standardized-audio-context": "^25.3.70",
|
||||
"tslib": "^2.3.1"
|
||||
}
|
||||
},
|
||||
"node_modules/tough-cookie": {
|
||||
"version": "6.0.1",
|
||||
"resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.1.tgz",
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
"dependencies": {
|
||||
"@base-ui/react": "^1.3.0",
|
||||
"@dagrejs/dagre": "^3.0.0",
|
||||
"@monaco-editor/react": "^4.7.0",
|
||||
"@types/katex": "^0.16.8",
|
||||
"@xyflow/react": "^12.10.1",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
@@ -22,6 +23,7 @@
|
||||
"react-dom": "19.2.4",
|
||||
"shadcn": "^4.1.0",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
"tone": "^15.1.22",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"zustand": "^5.0.12"
|
||||
},
|
||||
|
||||
@@ -2,12 +2,13 @@
|
||||
|
||||
import Link from 'next/link';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { TreePine, Wrench, User, Trophy } from 'lucide-react';
|
||||
import { TreePine, Box, User } from 'lucide-react';
|
||||
import { XPBar } from '@/components/common/XPBar';
|
||||
import { StreakBadge } from '@/components/common/StreakBadge';
|
||||
|
||||
const navItems = [
|
||||
{ href: '/skill-tree', label: 'Árbol', icon: TreePine },
|
||||
{ href: '/sandbox', label: 'Sandbox', icon: Box },
|
||||
{ href: '/profile', label: 'Perfil', icon: User },
|
||||
];
|
||||
|
||||
|
||||
164
src/app/(main)/sandbox/page.tsx
Normal file
164
src/app/(main)/sandbox/page.tsx
Normal file
@@ -0,0 +1,164 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { Card } from '@/components/ui/card';
|
||||
import { ArrowLeft } from 'lucide-react';
|
||||
|
||||
type SandboxMode = null | 'synth' | 'circuit' | 'electronics' | 'code-editor' | 'pixel-editor';
|
||||
|
||||
const MODES: { id: SandboxMode; title: string; icon: string; description: string; tag: string; tagColor: string }[] = [
|
||||
{
|
||||
id: 'synth',
|
||||
title: 'Sintetizador Modular',
|
||||
icon: '🎹',
|
||||
description: 'Osciladores, filtros, envolventes, efectos — conecta módulos y crea sonido',
|
||||
tag: 'Tone.js',
|
||||
tagColor: 'text-orange-400',
|
||||
},
|
||||
{
|
||||
id: 'circuit',
|
||||
title: 'Circuit Builder',
|
||||
icon: '⚡',
|
||||
description: 'Puertas lógicas, cables y tablas de verdad',
|
||||
tag: 'Logic',
|
||||
tagColor: 'text-green-400',
|
||||
},
|
||||
{
|
||||
id: 'electronics',
|
||||
title: 'Electronics Lab',
|
||||
icon: '🔋',
|
||||
description: 'Simulador SPICE — fuentes, resistencias, condensadores, transistores',
|
||||
tag: 'SPICE',
|
||||
tagColor: 'text-amber-400',
|
||||
},
|
||||
{
|
||||
id: 'code-editor',
|
||||
title: 'Code Editor',
|
||||
icon: '📝',
|
||||
description: 'Editor de código con Monaco — ASM, C, Verilog, JavaScript',
|
||||
tag: 'Monaco',
|
||||
tagColor: 'text-blue-400',
|
||||
},
|
||||
{
|
||||
id: 'pixel-editor',
|
||||
title: 'Pixel Editor',
|
||||
icon: '👾',
|
||||
description: 'Editor de sprites — dibuja pixel art con paleta de colores',
|
||||
tag: 'Editor',
|
||||
tagColor: 'text-purple-400',
|
||||
},
|
||||
];
|
||||
|
||||
// Lazy imports
|
||||
import { ModularSynth } from '@/components/workbench/modules/synth/ModularSynth';
|
||||
import { CircuitBuilder } from '@/components/workbench/modules/circuit-builder/CircuitBuilder';
|
||||
import { ElectronicsLab } from '@/components/workbench/modules/electronics/ElectronicsLab';
|
||||
import { CodeEditorWorkbench } from '@/components/workbench/modules/code-editor/CodeEditorWorkbench';
|
||||
import { PixelEditor } from '@/components/workbench/modules/pixel-editor/PixelEditor';
|
||||
|
||||
function SandboxEditor({ mode, onBack }: { mode: SandboxMode; onBack: () => void }) {
|
||||
const info = MODES.find((m) => m.id === mode)!;
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col">
|
||||
<header className="px-4 py-3 border-b border-border bg-card/50 flex items-center gap-3 shrink-0">
|
||||
<button onClick={onBack} className="text-muted-foreground hover:text-foreground">
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
</button>
|
||||
<span className="text-lg">{info.icon}</span>
|
||||
<h1 className="text-base font-bold">{info.title}</h1>
|
||||
<span className="text-xs text-muted-foreground">Sandbox</span>
|
||||
</header>
|
||||
<div className={`flex-1 min-h-0 ${mode === 'synth' ? '' : 'overflow-auto p-4'}`}>
|
||||
{mode === 'synth' && <ModularSynth />}
|
||||
{mode === 'circuit' && (
|
||||
<CircuitBuilder
|
||||
content={{
|
||||
type: 'circuit-builder',
|
||||
availableGates: ['AND', 'OR', 'NOT', 'NAND', 'NOR', 'XOR', 'XNOR'],
|
||||
inputLabels: ['A', 'B', 'C'],
|
||||
outputLabels: ['Y', 'Z'],
|
||||
truthTable: [],
|
||||
}}
|
||||
onCircuitChange={() => {}}
|
||||
/>
|
||||
)}
|
||||
{mode === 'electronics' && (
|
||||
<ElectronicsLab
|
||||
content={{
|
||||
type: 'electronics-lab',
|
||||
availableComponents: [
|
||||
'voltage-source', 'resistor', 'capacitor', 'led', 'switch',
|
||||
'nmos', 'pmos', 'ground', 'voltmeter', 'ammeter',
|
||||
],
|
||||
probes: [],
|
||||
}}
|
||||
onCircuitChange={() => {}}
|
||||
/>
|
||||
)}
|
||||
{mode === 'code-editor' && (
|
||||
<CodeEditorWorkbench
|
||||
content={{
|
||||
type: 'code-editor',
|
||||
language: 'javascript',
|
||||
starterCode: '// Sandbox — escribe lo que quieras\n\nfunction hello() {\n return "Hello, World!";\n}\n\nreturn hello();',
|
||||
testCases: [],
|
||||
}}
|
||||
onCodeChange={() => {}}
|
||||
/>
|
||||
)}
|
||||
{mode === 'pixel-editor' && (
|
||||
<PixelEditor
|
||||
content={{
|
||||
type: 'pixel-editor',
|
||||
width: 16,
|
||||
height: 16,
|
||||
palette: ['#000000', '#ffffff', '#ef4444', '#f59e0b', '#22c55e', '#3b82f6', '#8b5cf6', '#ec4899',
|
||||
'#06b6d4', '#84cc16', '#f97316', '#64748b', '#1e293b', '#fbbf24', '#a78bfa', '#fb7185'],
|
||||
mode: 'freeform',
|
||||
}}
|
||||
onGridChange={() => {}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function SandboxPage() {
|
||||
const [mode, setMode] = useState<SandboxMode>(null);
|
||||
|
||||
if (mode) {
|
||||
return <SandboxEditor mode={mode} onBack={() => setMode(null)} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-full overflow-auto">
|
||||
<header className="px-6 py-4 border-b border-border bg-card/50 backdrop-blur-sm">
|
||||
<h1 className="text-xl font-bold">Sandbox</h1>
|
||||
<p className="text-sm text-muted-foreground">Modo libre — experimenta con todas las herramientas</p>
|
||||
</header>
|
||||
<div className="p-6 max-w-3xl mx-auto">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
{MODES.map((m) => (
|
||||
<Card key={m.id}
|
||||
className="p-5 cursor-pointer hover:border-primary/50 hover:shadow-lg transition-all group"
|
||||
onClick={() => setMode(m.id)}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<span className="text-3xl group-hover:scale-110 transition-transform">{m.icon}</span>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="font-bold">{m.title}</h3>
|
||||
<span className={`text-[10px] font-medium ${m.tagColor}`}>{m.tag}</span>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-1">{m.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -13,6 +13,9 @@ import { MathInput } from './modules/MathInput';
|
||||
import { MultipleChoice } from './modules/MultipleChoice';
|
||||
import { CircuitBuilder } from './modules/circuit-builder/CircuitBuilder';
|
||||
import { ElectronicsLab } from './modules/electronics/ElectronicsLab';
|
||||
import { CodeEditorWorkbench } from './modules/code-editor/CodeEditorWorkbench';
|
||||
import { SignalPlayground } from './modules/signal-playground/SignalPlayground';
|
||||
import { PixelEditor } from './modules/pixel-editor/PixelEditor';
|
||||
import { Scratchpad } from './Scratchpad';
|
||||
import { ExplanationRenderer } from './ExplanationRenderer';
|
||||
|
||||
@@ -309,6 +312,27 @@ export function WorkbenchShell({ challenge }: WorkbenchShellProps) {
|
||||
disabled={done || phase === 'wrong-shake'}
|
||||
/>
|
||||
)}
|
||||
{challenge.content.type === 'code-editor' && (
|
||||
<CodeEditorWorkbench
|
||||
content={challenge.content}
|
||||
onCodeChange={(code) => setAnswer(code)}
|
||||
disabled={done || phase === 'wrong-shake'}
|
||||
/>
|
||||
)}
|
||||
{challenge.content.type === 'signal-playground' && (
|
||||
<SignalPlayground
|
||||
content={challenge.content}
|
||||
onStateChange={(state) => setAnswer(state)}
|
||||
disabled={done || phase === 'wrong-shake'}
|
||||
/>
|
||||
)}
|
||||
{challenge.content.type === 'pixel-editor' && (
|
||||
<PixelEditor
|
||||
content={challenge.content}
|
||||
onGridChange={(grid) => setAnswer(grid)}
|
||||
disabled={done || phase === 'wrong-shake'}
|
||||
/>
|
||||
)}
|
||||
{/* Wrong attempt feedback inline */}
|
||||
{phase === 'wrong-shake' && (
|
||||
<p className="text-sm text-red-400 mt-3 text-center">
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useCallback, useRef } from 'react';
|
||||
import Editor from '@monaco-editor/react';
|
||||
import { CodeEditorContent } from '@/types/challenge';
|
||||
import { Play, CheckCircle2, XCircle, Maximize2, Minimize2 } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { createPortal } from 'react-dom';
|
||||
|
||||
interface CodeEditorWorkbenchProps {
|
||||
content: CodeEditorContent;
|
||||
onCodeChange: (code: string) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const LANG_MAP: Record<string, string> = {
|
||||
asm: 'mips',
|
||||
c: 'c',
|
||||
verilog: 'systemverilog',
|
||||
html: 'html',
|
||||
javascript: 'javascript',
|
||||
};
|
||||
|
||||
// Simple JS evaluator for test cases
|
||||
function runCode(code: string, input: string, language: string): { output: string; error?: string } {
|
||||
if (language === 'javascript') {
|
||||
try {
|
||||
const fn = new Function('input', `${code}\n`);
|
||||
const result = fn(input);
|
||||
return { output: String(result ?? '') };
|
||||
} catch (e: unknown) {
|
||||
return { output: '', error: String(e) };
|
||||
}
|
||||
}
|
||||
// For non-JS languages, just return the code itself for verification
|
||||
return { output: code.trim() };
|
||||
}
|
||||
|
||||
export function CodeEditorWorkbench({ content, onCodeChange, disabled }: CodeEditorWorkbenchProps) {
|
||||
const [code, setCode] = useState(content.starterCode);
|
||||
const [testResults, setTestResults] = useState<Array<{ passed: boolean; actual: string; error?: string }>>([]);
|
||||
const [fullscreen, setFullscreen] = useState(false);
|
||||
const editorRef = useRef<unknown>(null);
|
||||
|
||||
const handleEditorChange = useCallback((value: string | undefined) => {
|
||||
const v = value ?? '';
|
||||
setCode(v);
|
||||
onCodeChange(v);
|
||||
}, [onCodeChange]);
|
||||
|
||||
const handleRun = useCallback(() => {
|
||||
const results = content.testCases.map((tc) => {
|
||||
const { output, error } = runCode(code, tc.input, content.language);
|
||||
const passed = output.trim() === tc.expectedOutput.trim();
|
||||
return { passed, actual: output.trim(), error };
|
||||
});
|
||||
setTestResults(results);
|
||||
}, [code, content]);
|
||||
|
||||
const allPassed = testResults.length > 0 && testResults.every((r) => r.passed);
|
||||
|
||||
const editorComponent = (fs: boolean) => (
|
||||
<div className={fs ? 'flex-1 min-h-0' : ''}>
|
||||
<Editor
|
||||
height={fs ? '100%' : '300px'}
|
||||
language={LANG_MAP[content.language] || content.language}
|
||||
value={code}
|
||||
onChange={handleEditorChange}
|
||||
onMount={(editor) => { editorRef.current = editor; }}
|
||||
theme="vs-dark"
|
||||
options={{
|
||||
minimap: { enabled: false },
|
||||
fontSize: 14,
|
||||
lineNumbers: 'on',
|
||||
scrollBeyondLastLine: false,
|
||||
readOnly: disabled,
|
||||
automaticLayout: true,
|
||||
tabSize: 2,
|
||||
wordWrap: 'on',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
const testPanel = (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Button size="sm" onClick={handleRun} disabled={disabled}>
|
||||
<Play className="w-3.5 h-3.5 mr-1.5" />
|
||||
Ejecutar tests
|
||||
</Button>
|
||||
{allPassed && <span className="text-xs text-green-500 font-medium">Todos los tests pasan</span>}
|
||||
<div className="flex-1" />
|
||||
<Button variant="ghost" size="sm" onClick={() => setFullscreen(!fullscreen)}>
|
||||
{fullscreen ? <Minimize2 className="w-3.5 h-3.5" /> : <Maximize2 className="w-3.5 h-3.5" />}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{content.testCases.map((tc, i) => {
|
||||
const result = testResults[i];
|
||||
return (
|
||||
<div key={i} className={`text-xs font-mono p-2 rounded border ${
|
||||
result ? (result.passed ? 'border-green-500/30 bg-green-500/5' : 'border-red-500/30 bg-red-500/5') : 'border-border'
|
||||
}`}>
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
{result && (result.passed
|
||||
? <CheckCircle2 className="w-3 h-3 text-green-500" />
|
||||
: <XCircle className="w-3 h-3 text-red-500" />
|
||||
)}
|
||||
<span className="text-muted-foreground">{tc.label || `Test ${i + 1}`}</span>
|
||||
</div>
|
||||
{tc.input && <div><span className="text-muted-foreground">Input:</span> {tc.input}</div>}
|
||||
<div><span className="text-muted-foreground">Esperado:</span> {tc.expectedOutput}</div>
|
||||
{result && <div><span className="text-muted-foreground">Tu resultado:</span> {result.actual || <span className="text-muted-foreground/50">(vacío)</span>}</div>}
|
||||
{result?.error && <div className="text-red-400 mt-1">{result.error}</div>}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
|
||||
// Fullscreen portal
|
||||
const fullscreenOverlay = fullscreen && createPortal(
|
||||
<div className="fixed inset-0 z-[500] bg-[#1e1e1e] flex flex-col animate-fade-in"
|
||||
onKeyDown={(e) => { if (e.key === 'Escape') { e.stopPropagation(); setFullscreen(false); } }}>
|
||||
<div className="flex-1 min-h-0 flex">
|
||||
<div className="flex-1 min-h-0">{editorComponent(true)}</div>
|
||||
<div className="w-80 shrink-0 border-l border-border bg-card/50 overflow-y-auto p-3">
|
||||
{testPanel}
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="border border-border rounded-lg overflow-hidden">
|
||||
{editorComponent(false)}
|
||||
</div>
|
||||
{testPanel}
|
||||
{fullscreenOverlay}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
228
src/components/workbench/modules/pixel-editor/PixelEditor.tsx
Normal file
228
src/components/workbench/modules/pixel-editor/PixelEditor.tsx
Normal file
@@ -0,0 +1,228 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useCallback, useRef, useEffect } from 'react';
|
||||
import { PixelEditorContent } from '@/types/challenge';
|
||||
import { Maximize2, Minimize2, Eraser, Trash2 } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { createPortal } from 'react-dom';
|
||||
|
||||
interface PixelEditorProps {
|
||||
content: PixelEditorContent;
|
||||
onGridChange: (grid: string) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export function PixelEditor({ content, onGridChange, disabled }: PixelEditorProps) {
|
||||
const { width, height, palette } = content;
|
||||
const [grid, setGrid] = useState<number[][]>(() =>
|
||||
Array.from({ length: height }, () => Array(width).fill(0))
|
||||
);
|
||||
const [selectedColor, setSelectedColor] = useState(1);
|
||||
const [isErasing, setIsErasing] = useState(false);
|
||||
const [isDrawing, setIsDrawing] = useState(false);
|
||||
const [fullscreen, setFullscreen] = useState(false);
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
|
||||
// Sync state
|
||||
useEffect(() => {
|
||||
onGridChange(JSON.stringify(grid));
|
||||
}, [grid, onGridChange]);
|
||||
|
||||
// Draw grid on canvas
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return;
|
||||
|
||||
const cellW = canvas.width / width;
|
||||
const cellH = canvas.height / height;
|
||||
|
||||
// Background
|
||||
ctx.fillStyle = '#0a0a0a';
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||
|
||||
// Cells
|
||||
for (let y = 0; y < height; y++) {
|
||||
for (let x = 0; x < width; x++) {
|
||||
const colorIdx = grid[y][x];
|
||||
ctx.fillStyle = palette[colorIdx] || '#000';
|
||||
ctx.fillRect(x * cellW, y * cellH, cellW, cellH);
|
||||
}
|
||||
}
|
||||
|
||||
// Grid lines
|
||||
ctx.strokeStyle = '#333';
|
||||
ctx.lineWidth = 0.5;
|
||||
for (let x = 0; x <= width; x++) {
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x * cellW, 0);
|
||||
ctx.lineTo(x * cellW, canvas.height);
|
||||
ctx.stroke();
|
||||
}
|
||||
for (let y = 0; y <= height; y++) {
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(0, y * cellH);
|
||||
ctx.lineTo(canvas.width, y * cellH);
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
// Target overlay if in match mode
|
||||
if (content.mode === 'match' && content.targetImage) {
|
||||
// Draw small target preview in corner
|
||||
const previewSize = Math.min(canvas.width, canvas.height) * 0.25;
|
||||
const px = canvas.width - previewSize - 4;
|
||||
const py = 4;
|
||||
const pcW = previewSize / width;
|
||||
const pcH = previewSize / height;
|
||||
ctx.globalAlpha = 0.8;
|
||||
ctx.fillStyle = '#000';
|
||||
ctx.fillRect(px - 2, py - 2, previewSize + 4, previewSize + 4);
|
||||
for (let y2 = 0; y2 < height; y2++) {
|
||||
for (let x2 = 0; x2 < width; x2++) {
|
||||
const ci = content.targetImage[y2]?.[x2] ?? 0;
|
||||
ctx.fillStyle = palette[ci] || '#000';
|
||||
ctx.fillRect(px + x2 * pcW, py + y2 * pcH, pcW, pcH);
|
||||
}
|
||||
}
|
||||
ctx.globalAlpha = 1;
|
||||
ctx.strokeStyle = '#fff';
|
||||
ctx.lineWidth = 1;
|
||||
ctx.strokeRect(px - 2, py - 2, previewSize + 4, previewSize + 4);
|
||||
ctx.fillStyle = '#fff';
|
||||
ctx.font = '10px monospace';
|
||||
ctx.fillText('Objetivo', px, py + previewSize + 14);
|
||||
}
|
||||
}, [grid, width, height, palette, content]);
|
||||
|
||||
const getCell = useCallback((e: React.MouseEvent<HTMLCanvasElement>) => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return null;
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const x = Math.floor(((e.clientX - rect.left) / rect.width) * width);
|
||||
const y = Math.floor(((e.clientY - rect.top) / rect.height) * height);
|
||||
if (x < 0 || x >= width || y < 0 || y >= height) return null;
|
||||
return { x, y };
|
||||
}, [width, height]);
|
||||
|
||||
const paint = useCallback((x: number, y: number) => {
|
||||
if (disabled) return;
|
||||
setGrid((prev) => {
|
||||
const newGrid = prev.map((row) => [...row]);
|
||||
newGrid[y][x] = isErasing ? 0 : selectedColor;
|
||||
return newGrid;
|
||||
});
|
||||
}, [disabled, isErasing, selectedColor]);
|
||||
|
||||
const handleMouseDown = useCallback((e: React.MouseEvent<HTMLCanvasElement>) => {
|
||||
const cell = getCell(e);
|
||||
if (cell) { paint(cell.x, cell.y); setIsDrawing(true); }
|
||||
}, [getCell, paint]);
|
||||
|
||||
const handleMouseMove = useCallback((e: React.MouseEvent<HTMLCanvasElement>) => {
|
||||
if (!isDrawing) return;
|
||||
const cell = getCell(e);
|
||||
if (cell) paint(cell.x, cell.y);
|
||||
}, [isDrawing, getCell, paint]);
|
||||
|
||||
const handleMouseUp = useCallback(() => setIsDrawing(false), []);
|
||||
|
||||
const clearGrid = useCallback(() => {
|
||||
setGrid(Array.from({ length: height }, () => Array(width).fill(0)));
|
||||
}, [width, height]);
|
||||
|
||||
// Match percentage
|
||||
const matchPct = content.mode === 'match' && content.targetImage
|
||||
? (() => {
|
||||
let total = 0;
|
||||
let matches = 0;
|
||||
for (let y = 0; y < height; y++) {
|
||||
for (let x = 0; x < width; x++) {
|
||||
total++;
|
||||
if (grid[y][x] === (content.targetImage[y]?.[x] ?? 0)) matches++;
|
||||
}
|
||||
}
|
||||
return Math.round((matches / total) * 100);
|
||||
})()
|
||||
: null;
|
||||
|
||||
const canvasSize = fullscreen ? Math.min(600, width * 40) : Math.min(400, width * 30);
|
||||
|
||||
const editorUI = (fs: boolean) => (
|
||||
<div className={`${fs ? 'flex gap-6 items-start justify-center h-full p-6' : 'space-y-3'}`}>
|
||||
{/* Canvas */}
|
||||
<div className={fs ? '' : ''}>
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
width={width * 20}
|
||||
height={height * 20}
|
||||
className="border border-border rounded cursor-crosshair"
|
||||
style={{
|
||||
width: canvasSize,
|
||||
height: canvasSize * (height / width),
|
||||
imageRendering: 'pixelated',
|
||||
}}
|
||||
onMouseDown={handleMouseDown}
|
||||
onMouseMove={handleMouseMove}
|
||||
onMouseUp={handleMouseUp}
|
||||
onMouseLeave={handleMouseUp}
|
||||
/>
|
||||
{matchPct !== null && (
|
||||
<div className="mt-2 text-xs text-center">
|
||||
<span className={matchPct === 100 ? 'text-green-500 font-bold' : 'text-muted-foreground'}>
|
||||
Coincidencia: {matchPct}%
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Tools */}
|
||||
<div className={`${fs ? 'w-40 space-y-3' : 'flex items-center gap-3 flex-wrap'}`}>
|
||||
{/* Palette */}
|
||||
<div className={fs ? 'space-y-1.5' : 'flex gap-1.5'}>
|
||||
{fs && <p className="text-xs text-muted-foreground mb-1">Paleta</p>}
|
||||
{palette.map((color, i) => (
|
||||
<button key={i}
|
||||
onClick={() => { setSelectedColor(i); setIsErasing(false); }}
|
||||
className={`rounded border-2 transition-transform ${
|
||||
selectedColor === i && !isErasing ? 'border-primary scale-110' : 'border-transparent hover:scale-105'
|
||||
}`}
|
||||
style={{ backgroundColor: color, width: fs ? 28 : 24, height: fs ? 28 : 24 }}
|
||||
title={`Color ${i}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className={fs ? 'space-y-1.5' : 'flex gap-1.5'}>
|
||||
<Button variant={isErasing ? 'default' : 'ghost'} size="sm"
|
||||
onClick={() => setIsErasing(!isErasing)}>
|
||||
<Eraser className="w-3 h-3 mr-1" /> Borrar
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onClick={clearGrid}>
|
||||
<Trash2 className="w-3 h-3 mr-1" /> Limpiar
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onClick={() => setFullscreen(!fullscreen)}>
|
||||
{fullscreen ? <Minimize2 className="w-3 h-3" /> : <Maximize2 className="w-3 h-3" />}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const fullscreenOverlay = fullscreen && createPortal(
|
||||
<div className="fixed inset-0 z-[500] bg-[#050510] flex flex-col animate-fade-in"
|
||||
onKeyDown={(e) => { if (e.key === 'Escape') { e.stopPropagation(); setFullscreen(false); } }}>
|
||||
<div className="flex-1 flex items-center justify-center">
|
||||
{editorUI(true)}
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{editorUI(false)}
|
||||
{fullscreenOverlay}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,297 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useCallback, useRef, useEffect } from 'react';
|
||||
import { SignalPlaygroundContent } from '@/types/challenge';
|
||||
import { Play, Square, Maximize2, Minimize2 } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { createPortal } from 'react-dom';
|
||||
|
||||
interface SignalPlaygroundProps {
|
||||
content: SignalPlaygroundContent;
|
||||
onStateChange: (state: string) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
type WaveType = 'sine' | 'square' | 'sawtooth' | 'triangle';
|
||||
const WAVE_TYPES: WaveType[] = ['sine', 'square', 'sawtooth', 'triangle'];
|
||||
const WAVE_LABELS: Record<WaveType, string> = {
|
||||
sine: 'Sinusoidal',
|
||||
square: 'Cuadrada',
|
||||
sawtooth: 'Sierra',
|
||||
triangle: 'Triangular',
|
||||
};
|
||||
|
||||
export function SignalPlayground({ content, onStateChange, disabled }: SignalPlaygroundProps) {
|
||||
const [frequency, setFrequency] = useState(content.targetFrequency ?? 440);
|
||||
const [waveform, setWaveform] = useState<WaveType>(content.targetWaveform ?? 'sine');
|
||||
const [volume, setVolume] = useState(0.3);
|
||||
const [playing, setPlaying] = useState(false);
|
||||
const [fullscreen, setFullscreen] = useState(false);
|
||||
const [filterFreq, setFilterFreq] = useState(1000);
|
||||
const [filterType, setFilterType] = useState<BiquadFilterType>('lowpass');
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const audioCtxRef = useRef<AudioContext | null>(null);
|
||||
const oscRef = useRef<OscillatorNode | null>(null);
|
||||
const gainRef = useRef<GainNode | null>(null);
|
||||
const filterRef = useRef<BiquadFilterNode | null>(null);
|
||||
const analyserRef = useRef<AnalyserNode | null>(null);
|
||||
const animRef = useRef<number>(0);
|
||||
|
||||
const hasFilter = content.mode === 'filter' || content.mode === 'synth';
|
||||
|
||||
// Sync state for verification
|
||||
useEffect(() => {
|
||||
onStateChange(JSON.stringify({ frequency, waveform, volume, filterFreq, filterType }));
|
||||
}, [frequency, waveform, volume, filterFreq, filterType, onStateChange]);
|
||||
|
||||
const startAudio = useCallback(() => {
|
||||
if (audioCtxRef.current) return;
|
||||
const ctx = new AudioContext();
|
||||
const osc = ctx.createOscillator();
|
||||
const gain = ctx.createGain();
|
||||
const analyser = ctx.createAnalyser();
|
||||
analyser.fftSize = 2048;
|
||||
|
||||
osc.type = waveform;
|
||||
osc.frequency.value = frequency;
|
||||
gain.gain.value = volume;
|
||||
|
||||
if (hasFilter) {
|
||||
const filter = ctx.createBiquadFilter();
|
||||
filter.type = filterType;
|
||||
filter.frequency.value = filterFreq;
|
||||
filter.Q.value = 1;
|
||||
osc.connect(filter);
|
||||
filter.connect(gain);
|
||||
filterRef.current = filter;
|
||||
} else {
|
||||
osc.connect(gain);
|
||||
}
|
||||
|
||||
gain.connect(analyser);
|
||||
analyser.connect(ctx.destination);
|
||||
osc.start();
|
||||
|
||||
audioCtxRef.current = ctx;
|
||||
oscRef.current = osc;
|
||||
gainRef.current = gain;
|
||||
analyserRef.current = analyser;
|
||||
setPlaying(true);
|
||||
}, [waveform, frequency, volume, hasFilter, filterFreq, filterType]);
|
||||
|
||||
const stopAudio = useCallback(() => {
|
||||
oscRef.current?.stop();
|
||||
audioCtxRef.current?.close();
|
||||
audioCtxRef.current = null;
|
||||
oscRef.current = null;
|
||||
gainRef.current = null;
|
||||
filterRef.current = null;
|
||||
analyserRef.current = null;
|
||||
setPlaying(false);
|
||||
}, []);
|
||||
|
||||
// Update live parameters
|
||||
useEffect(() => {
|
||||
if (oscRef.current) {
|
||||
oscRef.current.frequency.value = frequency;
|
||||
oscRef.current.type = waveform;
|
||||
}
|
||||
if (gainRef.current) gainRef.current.gain.value = volume;
|
||||
if (filterRef.current) {
|
||||
filterRef.current.frequency.value = filterFreq;
|
||||
filterRef.current.type = filterType;
|
||||
}
|
||||
}, [frequency, waveform, volume, filterFreq, filterType]);
|
||||
|
||||
// Waveform visualization
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return;
|
||||
|
||||
const draw = () => {
|
||||
const w = canvas.width;
|
||||
const h = canvas.height;
|
||||
ctx.fillStyle = '#0a0a0a';
|
||||
ctx.fillRect(0, 0, w, h);
|
||||
|
||||
// Grid
|
||||
ctx.strokeStyle = '#222';
|
||||
ctx.lineWidth = 1;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(0, h / 2); ctx.lineTo(w, h / 2);
|
||||
ctx.stroke();
|
||||
|
||||
if (analyserRef.current) {
|
||||
// Live waveform from analyser
|
||||
const bufferLength = analyserRef.current.fftSize;
|
||||
const dataArray = new Float32Array(bufferLength);
|
||||
analyserRef.current.getFloatTimeDomainData(dataArray);
|
||||
|
||||
ctx.strokeStyle = '#6366f1';
|
||||
ctx.lineWidth = 2;
|
||||
ctx.beginPath();
|
||||
const sliceWidth = w / bufferLength;
|
||||
let x = 0;
|
||||
for (let i = 0; i < bufferLength; i++) {
|
||||
const y = (1 - dataArray[i]) * h / 2;
|
||||
if (i === 0) ctx.moveTo(x, y);
|
||||
else ctx.lineTo(x, y);
|
||||
x += sliceWidth;
|
||||
}
|
||||
ctx.stroke();
|
||||
} else {
|
||||
// Static preview
|
||||
ctx.strokeStyle = '#6366f1';
|
||||
ctx.lineWidth = 2;
|
||||
ctx.beginPath();
|
||||
for (let x = 0; x < w; x++) {
|
||||
const t = (x / w) * 4 * Math.PI;
|
||||
let y = 0;
|
||||
switch (waveform) {
|
||||
case 'sine': y = Math.sin(t); break;
|
||||
case 'square': y = Math.sin(t) >= 0 ? 1 : -1; break;
|
||||
case 'sawtooth': y = 2 * ((t / (2 * Math.PI)) % 1) - 1; break;
|
||||
case 'triangle': y = 2 * Math.abs(2 * ((t / (2 * Math.PI)) % 1) - 1) - 1; break;
|
||||
}
|
||||
const py = (1 - y * 0.8) * h / 2;
|
||||
if (x === 0) ctx.moveTo(x, py);
|
||||
else ctx.lineTo(x, py);
|
||||
}
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
animRef.current = requestAnimationFrame(draw);
|
||||
};
|
||||
|
||||
draw();
|
||||
return () => cancelAnimationFrame(animRef.current);
|
||||
}, [waveform, playing]);
|
||||
|
||||
// Cleanup on unmount
|
||||
useEffect(() => {
|
||||
return () => { stopAudio(); };
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const controls = (fs: boolean) => (
|
||||
<div className={`space-y-4 ${fs ? 'p-4' : ''}`}>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button size="sm" onClick={playing ? stopAudio : startAudio} disabled={disabled}
|
||||
className={playing ? 'bg-red-600 hover:bg-red-700' : ''}>
|
||||
{playing ? <Square className="w-3.5 h-3.5 mr-1.5" /> : <Play className="w-3.5 h-3.5 mr-1.5" />}
|
||||
{playing ? 'Parar' : 'Reproducir'}
|
||||
</Button>
|
||||
<span className={`text-xs ${playing ? 'text-green-400' : 'text-muted-foreground'}`}>
|
||||
{playing ? '♪ Reproduciendo' : '○ Silencio'}
|
||||
</span>
|
||||
{!fs && (
|
||||
<Button variant="ghost" size="sm" className="ml-auto" onClick={() => setFullscreen(true)}>
|
||||
<Maximize2 className="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
{fs && (
|
||||
<Button variant="ghost" size="sm" className="ml-auto" onClick={() => setFullscreen(false)}>
|
||||
<Minimize2 className="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Waveform selector */}
|
||||
<div>
|
||||
<label className="text-xs text-muted-foreground block mb-1.5">Forma de onda</label>
|
||||
<div className="flex gap-1.5">
|
||||
{WAVE_TYPES.map((w) => (
|
||||
<button key={w}
|
||||
onClick={() => setWaveform(w)}
|
||||
className={`px-3 py-1.5 text-xs rounded border transition-colors ${
|
||||
waveform === w ? 'bg-primary/20 border-primary text-primary' : 'bg-muted border-border hover:border-primary/50'
|
||||
}`}
|
||||
>{WAVE_LABELS[w]}</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Frequency */}
|
||||
<div>
|
||||
<label className="text-xs text-muted-foreground block mb-1.5">
|
||||
Frecuencia: <span className="text-foreground font-mono">{frequency} Hz</span>
|
||||
</label>
|
||||
<input type="range" min="20" max="2000" step="1" value={frequency}
|
||||
onChange={(e) => setFrequency(Number(e.target.value))}
|
||||
className="w-full accent-primary" />
|
||||
</div>
|
||||
|
||||
{/* Volume */}
|
||||
<div>
|
||||
<label className="text-xs text-muted-foreground block mb-1.5">
|
||||
Volumen: <span className="text-foreground font-mono">{Math.round(volume * 100)}%</span>
|
||||
</label>
|
||||
<input type="range" min="0" max="1" step="0.01" value={volume}
|
||||
onChange={(e) => setVolume(Number(e.target.value))}
|
||||
className="w-full accent-primary" />
|
||||
</div>
|
||||
|
||||
{/* Filter controls */}
|
||||
{hasFilter && (
|
||||
<>
|
||||
<div>
|
||||
<label className="text-xs text-muted-foreground block mb-1.5">Tipo de filtro</label>
|
||||
<div className="flex gap-1.5">
|
||||
{(['lowpass', 'highpass', 'bandpass'] as BiquadFilterType[]).map((ft) => (
|
||||
<button key={ft}
|
||||
onClick={() => setFilterType(ft)}
|
||||
className={`px-3 py-1.5 text-xs rounded border transition-colors ${
|
||||
filterType === ft ? 'bg-primary/20 border-primary text-primary' : 'bg-muted border-border hover:border-primary/50'
|
||||
}`}
|
||||
>{ft === 'lowpass' ? 'Paso bajo' : ft === 'highpass' ? 'Paso alto' : 'Paso banda'}</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-muted-foreground block mb-1.5">
|
||||
Frecuencia de corte: <span className="text-foreground font-mono">{filterFreq} Hz</span>
|
||||
</label>
|
||||
<input type="range" min="20" max="5000" step="10" value={filterFreq}
|
||||
onChange={(e) => setFilterFreq(Number(e.target.value))}
|
||||
className="w-full accent-primary" />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<p className="text-xs text-muted-foreground">{content.instructions}</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
const canvas = (fs: boolean) => (
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
width={800}
|
||||
height={fs ? 400 : 200}
|
||||
className="w-full rounded-lg border border-border bg-[#0a0a0a]"
|
||||
style={{ height: fs ? '100%' : 200 }}
|
||||
/>
|
||||
);
|
||||
|
||||
const fullscreenOverlay = fullscreen && createPortal(
|
||||
<div className="fixed inset-0 z-[500] bg-[#050510] flex animate-fade-in"
|
||||
onKeyDown={(e) => { if (e.key === 'Escape') { e.stopPropagation(); setFullscreen(false); } }}>
|
||||
<div className="w-72 shrink-0 border-r border-border bg-card/30 overflow-y-auto">
|
||||
{controls(true)}
|
||||
</div>
|
||||
<div className="flex-1 p-4 flex items-center justify-center">
|
||||
{canvas(true)}
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{canvas(false)}
|
||||
{controls(false)}
|
||||
{fullscreenOverlay}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
146
src/components/workbench/modules/synth/Knob.tsx
Normal file
146
src/components/workbench/modules/synth/Knob.tsx
Normal file
@@ -0,0 +1,146 @@
|
||||
'use client';
|
||||
|
||||
import { useRef, useCallback, useState } from 'react';
|
||||
|
||||
const SIZE = 32;
|
||||
const RADIUS = 12;
|
||||
const START_ANGLE = 225;
|
||||
const END_ANGLE = -45;
|
||||
const RANGE = 270;
|
||||
|
||||
function polarToCart(cx: number, cy: number, r: number, deg: number) {
|
||||
const rad = (deg - 90) * Math.PI / 180;
|
||||
return { x: cx + r * Math.cos(rad), y: cy + r * Math.sin(rad) };
|
||||
}
|
||||
|
||||
function describeArc(cx: number, cy: number, r: number, startDeg: number, endDeg: number) {
|
||||
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}`;
|
||||
}
|
||||
|
||||
interface KnobProps {
|
||||
value: number;
|
||||
min: number;
|
||||
max: number;
|
||||
onChange: (v: number) => void;
|
||||
color?: string;
|
||||
modulated?: boolean;
|
||||
liveValue?: number;
|
||||
}
|
||||
|
||||
export function Knob({ value, min, max, onChange, color = 'var(--accent, #6366f1)', modulated = false, liveValue }: KnobProps) {
|
||||
const ref = useRef<SVGSVGElement>(null);
|
||||
const dragRef = useRef<{ startY: number; startValue: number } | null>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [editText, setEditText] = useState('');
|
||||
|
||||
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);
|
||||
|
||||
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 handlePointerDown = useCallback((e: React.PointerEvent) => {
|
||||
if (editing) return;
|
||||
e.preventDefault(); e.stopPropagation();
|
||||
dragRef.current = { startY: e.clientY, startValue: value };
|
||||
const handleMove = (me: PointerEvent) => {
|
||||
if (!dragRef.current) return;
|
||||
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: React.WheelEvent) => {
|
||||
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]);
|
||||
|
||||
const handleDoubleClick = useCallback((e: React.MouseEvent) => {
|
||||
e.preventDefault(); e.stopPropagation();
|
||||
setEditText(String(value));
|
||||
setEditing(true);
|
||||
setTimeout(() => inputRef.current?.focus(), 0);
|
||||
}, [value]);
|
||||
|
||||
const commitEdit = useCallback(() => {
|
||||
const parsed = parseFloat(editText);
|
||||
if (!isNaN(parsed)) {
|
||||
onChange(Math.max(min, Math.min(max, parsed)));
|
||||
}
|
||||
setEditing(false);
|
||||
}, [editText, min, max, onChange]);
|
||||
|
||||
if (editing) {
|
||||
return (
|
||||
<div className="knob-container" onWheel={(e) => e.stopPropagation()} style={{ width: SIZE, height: SIZE, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={editText}
|
||||
onChange={(e) => setEditText(e.target.value)}
|
||||
onKeyDown={(e) => { e.stopPropagation(); if (e.key === 'Enter') commitEdit(); if (e.key === 'Escape') setEditing(false); }}
|
||||
onBlur={commitEdit}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
style={{
|
||||
width: 48, height: 22, background: 'oklch(0.145 0 0)', border: '1px solid var(--accent, #6366f1)',
|
||||
borderRadius: 3, color: 'var(--accent, #6366f1)', fontSize: 10, textAlign: 'center',
|
||||
fontFamily: "'JetBrains Mono', monospace", outline: 'none',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ width: SIZE, height: SIZE, position: 'relative', flexShrink: 0 }} onWheel={handleWheel} onDoubleClick={handleDoubleClick}>
|
||||
<svg viewBox={`0 0 ${SIZE} ${SIZE}`} style={{ width: SIZE, height: SIZE, cursor: 'pointer' }}
|
||||
onPointerDown={handlePointerDown} ref={ref}>
|
||||
{/* Modulation glow ring */}
|
||||
{modulated && (
|
||||
<circle cx={cx} cy={cy} r={RADIUS + 1} fill="none" stroke={color} strokeWidth="1" strokeDasharray="3 2" opacity="0.7">
|
||||
<animateTransform attributeName="transform" type="rotate" from={`0 ${cx} ${cy}`} to={`360 ${cx} ${cy}`} dur="4s" repeatCount="indefinite" />
|
||||
</circle>
|
||||
)}
|
||||
{/* Track */}
|
||||
<path d={trackPath} fill="none" stroke="#333" strokeWidth="3" strokeLinecap="round" />
|
||||
{/* Fill */}
|
||||
{fillPath && <path d={fillPath} fill="none" stroke={color} strokeWidth="3" strokeLinecap="round" />}
|
||||
{/* Ghost dot at base value when modulated */}
|
||||
{liveValue !== undefined && (
|
||||
<circle cx={baseDotPos.x} cy={baseDotPos.y} r={1.5} fill="#fff" opacity="0.3" />
|
||||
)}
|
||||
{/* Current value dot */}
|
||||
<circle cx={dotPos.x} cy={dotPos.y} r={2} fill="#fff" />
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
507
src/components/workbench/modules/synth/ModularSynth.tsx
Normal file
507
src/components/workbench/modules/synth/ModularSynth.tsx
Normal file
@@ -0,0 +1,507 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useCallback, useRef, useEffect } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { MODULE_REGISTRY, getModuleDef, PORT_COLORS } from './moduleRegistry';
|
||||
import { SynthModule, SynthConnection } from './synthTypes';
|
||||
import { AudioNodeWrapper, createAudioNode, updateAudioParam, connectAudio, disconnectAudio, startAudioContext } from './audioEngine';
|
||||
import { SynthModuleNode } from './SynthModuleNode';
|
||||
import { Maximize2, Minimize2 } from 'lucide-react';
|
||||
|
||||
let nextModId = Date.now();
|
||||
|
||||
interface WiringState {
|
||||
moduleId: string;
|
||||
port: string;
|
||||
direction: 'input' | 'output';
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
// Colors matching app's dark theme (oklch-based shadcn)
|
||||
const COLORS = {
|
||||
bg: 'oklch(0.145 0 0)', // --background
|
||||
panel: 'oklch(0.178 0 0)', // slightly lighter than bg
|
||||
surface: 'oklch(0.205 0 0)', // --card
|
||||
surface2: 'oklch(0.235 0 0)', // --card lighter
|
||||
border: 'oklch(1 0 0 / 10%)', // --border
|
||||
text: 'oklch(0.985 0 0)', // --foreground
|
||||
text2: 'oklch(0.556 0 0)', // --muted-foreground
|
||||
accent: '#6366f1', // primary indigo (matches app primary)
|
||||
green: '#22c55e', // matches app green
|
||||
};
|
||||
|
||||
export function ModularSynth() {
|
||||
const [modules, setModules] = useState<SynthModule[]>([]);
|
||||
const [connections, setConnections] = useState<SynthConnection[]>([]);
|
||||
const [audioStarted, setAudioStarted] = useState(false);
|
||||
const [playing, setPlaying] = useState(false);
|
||||
const [fullscreen, setFullscreen] = useState(false);
|
||||
const [wiring, setWiring] = useState<WiringState | null>(null);
|
||||
const [mousePos, setMousePos] = useState({ x: 0, y: 0 });
|
||||
const [dragging, setDragging] = useState<{ id: string; ox: number; oy: number } | null>(null);
|
||||
|
||||
// Camera
|
||||
const [camX, setCamX] = useState(0);
|
||||
const [camY, setCamY] = useState(0);
|
||||
const [zoom, setZoom] = useState(1);
|
||||
const [panning, setPanning] = useState(false);
|
||||
const panStart = useRef({ x: 0, y: 0, camX: 0, camY: 0 });
|
||||
|
||||
// Audio node map
|
||||
const audioNodes = useRef<Map<string, AudioNodeWrapper>>(new Map());
|
||||
const portRefs = useRef<Map<string, HTMLDivElement>>(new Map());
|
||||
const canvasRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
audioNodes.current.forEach((w) => w.dispose());
|
||||
audioNodes.current.clear();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleStartAudio = useCallback(async () => {
|
||||
await startAudioContext();
|
||||
setAudioStarted(true);
|
||||
}, []);
|
||||
|
||||
// Play: create all audio nodes and connect them
|
||||
const handlePlay = useCallback(async () => {
|
||||
if (!audioStarted) {
|
||||
await startAudioContext();
|
||||
setAudioStarted(true);
|
||||
}
|
||||
// Create nodes for all modules
|
||||
for (const mod of modules) {
|
||||
if (!audioNodes.current.has(mod.id)) {
|
||||
const wrapper = createAudioNode(mod.type, mod.params);
|
||||
audioNodes.current.set(mod.id, wrapper);
|
||||
}
|
||||
}
|
||||
// Create all connections
|
||||
for (const conn of connections) {
|
||||
const fromW = audioNodes.current.get(conn.from.moduleId);
|
||||
const toW = audioNodes.current.get(conn.to.moduleId);
|
||||
if (fromW && toW) connectAudio(fromW, conn.from.port, toW, conn.to.port);
|
||||
}
|
||||
setPlaying(true);
|
||||
}, [audioStarted, modules, connections]);
|
||||
|
||||
// Stop: destroy all audio nodes
|
||||
const handleStop = useCallback(() => {
|
||||
audioNodes.current.forEach((w) => w.dispose());
|
||||
audioNodes.current.clear();
|
||||
setPlaying(false);
|
||||
}, []);
|
||||
|
||||
const getPortRef = useCallback((moduleId: string, port: string, dir: 'input' | 'output') => {
|
||||
return (el: HTMLDivElement | null) => {
|
||||
const key = `${moduleId}:${dir}:${port}`;
|
||||
if (el) portRefs.current.set(key, el);
|
||||
else portRefs.current.delete(key);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const getPortPos = useCallback((moduleId: string, port: string, dir: 'input' | 'output') => {
|
||||
const key = `${moduleId}:${dir}:${port}`;
|
||||
const el = portRefs.current.get(key);
|
||||
const canvas = canvasRef.current;
|
||||
if (!el || !canvas) return null;
|
||||
const elRect = el.getBoundingClientRect();
|
||||
const cRect = canvas.getBoundingClientRect();
|
||||
return {
|
||||
x: (elRect.left + elRect.width / 2 - cRect.left) / zoom - camX,
|
||||
y: (elRect.top + elRect.height / 2 - cRect.top) / zoom - camY,
|
||||
};
|
||||
}, [camX, camY, zoom]);
|
||||
|
||||
const screenToWorld = useCallback((sx: number, sy: number) => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return { x: 0, y: 0 };
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
return {
|
||||
x: (sx - rect.left) / zoom - camX,
|
||||
y: (sy - rect.top) / zoom - camY,
|
||||
};
|
||||
}, [camX, camY, zoom]);
|
||||
|
||||
// Add module
|
||||
const addModule = useCallback((type: string) => {
|
||||
const def = getModuleDef(type);
|
||||
if (!def) return;
|
||||
const id = `mod-${nextModId++}`;
|
||||
const params: Record<string, unknown> = {};
|
||||
for (const [key, paramDef] of Object.entries(def.params)) {
|
||||
params[key] = paramDef.default;
|
||||
}
|
||||
// Place near center of current view
|
||||
const canvas = canvasRef.current;
|
||||
let cx = 200, cy = 200;
|
||||
if (canvas) {
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
cx = (rect.width / 2) / zoom - camX + (Math.random() - 0.5) * 100;
|
||||
cy = (rect.height / 2) / zoom - camY + (Math.random() - 0.5) * 100;
|
||||
}
|
||||
const newMod: SynthModule = { id, type, x: cx, y: cy, params };
|
||||
setModules((prev) => [...prev, newMod]);
|
||||
setWiring(null);
|
||||
// If already playing, create audio node immediately
|
||||
if (playing) {
|
||||
const wrapper = createAudioNode(type, params);
|
||||
audioNodes.current.set(id, wrapper);
|
||||
}
|
||||
}, [playing, camX, camY, zoom]);
|
||||
|
||||
const deleteModule = useCallback((moduleId: string) => {
|
||||
setConnections((prev) => {
|
||||
const toRemove = prev.filter((c) => c.from.moduleId === moduleId || c.to.moduleId === moduleId);
|
||||
toRemove.forEach((c) => {
|
||||
const fromW = audioNodes.current.get(c.from.moduleId);
|
||||
const toW = audioNodes.current.get(c.to.moduleId);
|
||||
if (fromW && toW) disconnectAudio(fromW, c.from.port, toW, c.to.port);
|
||||
});
|
||||
return prev.filter((c) => c.from.moduleId !== moduleId && c.to.moduleId !== moduleId);
|
||||
});
|
||||
setModules((prev) => prev.filter((m) => m.id !== moduleId));
|
||||
const wrapper = audioNodes.current.get(moduleId);
|
||||
if (wrapper) { wrapper.dispose(); audioNodes.current.delete(moduleId); }
|
||||
}, []);
|
||||
|
||||
const handleParamChange = useCallback((moduleId: string, param: string, value: unknown) => {
|
||||
setModules((prev) => prev.map((m) =>
|
||||
m.id === moduleId ? { ...m, params: { ...m.params, [param]: value } } : m
|
||||
));
|
||||
const wrapper = audioNodes.current.get(moduleId);
|
||||
const mod = modules.find((m) => m.id === moduleId);
|
||||
if (wrapper && mod) updateAudioParam(wrapper, mod.type, param, value);
|
||||
}, [modules]);
|
||||
|
||||
// Module drag
|
||||
const handleDragStart = useCallback((moduleId: string, ox: number, oy: number) => {
|
||||
setDragging({ id: moduleId, ox: ox / zoom, oy: oy / zoom });
|
||||
}, [zoom]);
|
||||
|
||||
// Pan/zoom
|
||||
const handleCanvasMouseDown = useCallback((e: React.MouseEvent) => {
|
||||
// Middle mouse or right click → pan
|
||||
if (e.button === 1 || e.button === 2 || (e.button === 0 && e.altKey)) {
|
||||
e.preventDefault();
|
||||
setPanning(true);
|
||||
panStart.current = { x: e.clientX, y: e.clientY, camX, camY };
|
||||
return;
|
||||
}
|
||||
// Left click on empty canvas → deselect / cancel wiring
|
||||
if (e.button === 0 && !wiring) {
|
||||
// nothing
|
||||
}
|
||||
}, [camX, camY, wiring]);
|
||||
|
||||
const handleCanvasMouseMove = useCallback((e: React.MouseEvent) => {
|
||||
const world = screenToWorld(e.clientX, e.clientY);
|
||||
setMousePos(world);
|
||||
|
||||
if (panning) {
|
||||
const dx = e.clientX - panStart.current.x;
|
||||
const dy = e.clientY - panStart.current.y;
|
||||
setCamX(panStart.current.camX + dx / zoom);
|
||||
setCamY(panStart.current.camY + dy / zoom);
|
||||
return;
|
||||
}
|
||||
|
||||
if (dragging) {
|
||||
setModules((prev) => prev.map((m) =>
|
||||
m.id === dragging.id ? { ...m, x: world.x - dragging.ox, y: world.y - dragging.oy } : m
|
||||
));
|
||||
}
|
||||
}, [panning, dragging, zoom, screenToWorld]);
|
||||
|
||||
const handleCanvasMouseUp = useCallback(() => {
|
||||
setPanning(false);
|
||||
setDragging(null);
|
||||
if (wiring) setWiring(null);
|
||||
}, [wiring]);
|
||||
|
||||
const handleWheel = useCallback((e: React.WheelEvent) => {
|
||||
e.preventDefault();
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const mx = e.clientX - rect.left;
|
||||
const my = e.clientY - rect.top;
|
||||
|
||||
const factor = e.deltaY < 0 ? 1.1 : 0.9;
|
||||
const newZoom = Math.max(0.2, Math.min(3, zoom * factor));
|
||||
|
||||
// Zoom towards mouse position
|
||||
const wx = mx / zoom - camX;
|
||||
const wy = my / zoom - camY;
|
||||
const newCamX = mx / newZoom - wx;
|
||||
const newCamY = my / newZoom - wy;
|
||||
|
||||
setZoom(newZoom);
|
||||
setCamX(newCamX);
|
||||
setCamY(newCamY);
|
||||
}, [zoom, camX, camY]);
|
||||
|
||||
const resetView = useCallback(() => {
|
||||
setCamX(0); setCamY(0); setZoom(1);
|
||||
}, []);
|
||||
|
||||
// Wiring
|
||||
const handlePortMouseDown = useCallback((moduleId: string, port: string, direction: 'input' | 'output', x: number, y: number) => {
|
||||
const world = screenToWorld(x, y);
|
||||
setWiring({ moduleId, port, direction, ...world });
|
||||
}, [screenToWorld]);
|
||||
|
||||
const handlePortMouseUp = useCallback((moduleId: string, port: string, direction: 'input' | 'output') => {
|
||||
if (!wiring) return;
|
||||
if (wiring.moduleId === moduleId && wiring.port === port) { setWiring(null); return; }
|
||||
|
||||
let from = { moduleId: wiring.moduleId, port: wiring.port };
|
||||
let to = { moduleId, port };
|
||||
|
||||
if (wiring.direction === 'input' && direction === 'output') {
|
||||
[from, to] = [to, from];
|
||||
} else if (!(wiring.direction === 'output' && direction === 'input')) {
|
||||
setWiring(null); return;
|
||||
}
|
||||
|
||||
setConnections((prev) => {
|
||||
const existing = prev.find((c) => c.to.moduleId === to.moduleId && c.to.port === to.port);
|
||||
if (existing) {
|
||||
const fW = audioNodes.current.get(existing.from.moduleId);
|
||||
const tW = audioNodes.current.get(existing.to.moduleId);
|
||||
if (fW && tW) disconnectAudio(fW, existing.from.port, tW, existing.to.port);
|
||||
}
|
||||
const filtered = prev.filter((c) => !(c.to.moduleId === to.moduleId && c.to.port === to.port));
|
||||
return [...filtered, { id: `conn-${Date.now()}`, from, to }];
|
||||
});
|
||||
|
||||
const fromW = audioNodes.current.get(from.moduleId);
|
||||
const toW = audioNodes.current.get(to.moduleId);
|
||||
if (fromW && toW) connectAudio(fromW, from.port, toW, to.port);
|
||||
setWiring(null);
|
||||
}, [wiring]);
|
||||
|
||||
// Wire rendering
|
||||
const renderWires = () => {
|
||||
const lines: React.ReactElement[] = [];
|
||||
|
||||
for (const conn of connections) {
|
||||
const fromPos = getPortPos(conn.from.moduleId, conn.from.port, 'output');
|
||||
const toPos = getPortPos(conn.to.moduleId, conn.to.port, 'input');
|
||||
if (!fromPos || !toPos) continue;
|
||||
|
||||
const fromDef = getModuleDef(modules.find((m) => m.id === conn.from.moduleId)?.type ?? '');
|
||||
const portDef = fromDef?.outputs.find((p) => p.name === conn.from.port);
|
||||
const color = portDef ? PORT_COLORS[portDef.type] : '#666';
|
||||
|
||||
const dx = Math.max(50, Math.abs(toPos.x - fromPos.x) * 0.5);
|
||||
const d = `M${fromPos.x},${fromPos.y} C${fromPos.x + dx},${fromPos.y} ${toPos.x - dx},${toPos.y} ${toPos.x},${toPos.y}`;
|
||||
lines.push(
|
||||
<path key={conn.id} d={d} fill="none" stroke={color} strokeWidth={2.5 / zoom}
|
||||
strokeLinecap="round" opacity="0.8"
|
||||
filter={`drop-shadow(0 0 ${3 / zoom}px ${color}40)`}
|
||||
className="pointer-events-auto cursor-pointer hover:opacity-100"
|
||||
style={{ strokeWidth: 2.5 / zoom }}
|
||||
onClick={() => {
|
||||
const fromW = audioNodes.current.get(conn.from.moduleId);
|
||||
const toW = audioNodes.current.get(conn.to.moduleId);
|
||||
if (fromW && toW) disconnectAudio(fromW, conn.from.port, toW, conn.to.port);
|
||||
setConnections((prev) => prev.filter((c) => c.id !== conn.id));
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (wiring) {
|
||||
const dx = Math.max(50, Math.abs(mousePos.x - wiring.x) * 0.5);
|
||||
const d = `M${wiring.x},${wiring.y} C${wiring.x + dx},${wiring.y} ${mousePos.x - dx},${mousePos.y} ${mousePos.x},${mousePos.y}`;
|
||||
lines.push(
|
||||
<path key="preview" d={d} fill="none" stroke={COLORS.accent} strokeWidth={2 / zoom}
|
||||
strokeDasharray={`${6 / zoom} ${4 / zoom}`} opacity="0.5" pointerEvents="none" />
|
||||
);
|
||||
}
|
||||
|
||||
return lines;
|
||||
};
|
||||
|
||||
const categories = [...new Set(MODULE_REGISTRY.map((m) => m.category))];
|
||||
|
||||
const synthContent = (fs: boolean) => (
|
||||
<div className="flex flex-col h-full" style={{ background: COLORS.bg, color: COLORS.text, fontFamily: "'Inter', system-ui, sans-serif" }}>
|
||||
{/* Toolbar */}
|
||||
<div className="shrink-0 flex items-center gap-2 px-3" style={{ height: 40, background: COLORS.panel, borderBottom: `1px solid ${COLORS.border}`, zIndex: 10 }}>
|
||||
<span style={{ fontSize: 14, fontWeight: 700, letterSpacing: 1, textTransform: 'uppercase', color: COLORS.accent }}>
|
||||
SYNTH
|
||||
</span>
|
||||
<div style={{ width: 1, height: 20, background: COLORS.border }} />
|
||||
|
||||
{!playing ? (
|
||||
<button onClick={handlePlay}
|
||||
style={{ padding: '4px 12px', borderRadius: 4, border: `1px solid ${COLORS.green}`, background: 'transparent', color: COLORS.green, fontSize: 11, fontWeight: 600, cursor: 'pointer' }}>
|
||||
▶ PLAY
|
||||
</button>
|
||||
) : (
|
||||
<button onClick={handleStop}
|
||||
style={{ padding: '4px 12px', borderRadius: 4, border: `1px solid #ef4444`, background: 'transparent', color: '#ef4444', fontSize: 11, fontWeight: 600, cursor: 'pointer' }}>
|
||||
■ STOP
|
||||
</button>
|
||||
)}
|
||||
|
||||
<div style={{ width: 1, height: 20, background: COLORS.border }} />
|
||||
|
||||
<button onClick={() => { setModules([]); setConnections([]); audioNodes.current.forEach(w => w.dispose()); audioNodes.current.clear(); }}
|
||||
style={{ padding: '4px 10px', borderRadius: 4, border: 'none', background: 'transparent', color: COLORS.text2, fontSize: 11, cursor: 'pointer' }}>
|
||||
CLEAR
|
||||
</button>
|
||||
|
||||
<div className="flex-1" />
|
||||
|
||||
<button onClick={() => setFullscreen(!fs)}
|
||||
style={{ padding: '4px 8px', borderRadius: 4, border: 'none', background: 'transparent', color: COLORS.text2, cursor: 'pointer', display: 'flex', alignItems: 'center' }}>
|
||||
{fs ? <Minimize2 size={14} /> : <Maximize2 size={14} />}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Main area */}
|
||||
<div className="flex-1 relative overflow-hidden"
|
||||
onContextMenu={(e) => e.preventDefault()}>
|
||||
|
||||
{/* Canvas */}
|
||||
<div
|
||||
ref={canvasRef}
|
||||
className="absolute inset-0"
|
||||
style={{ cursor: panning ? 'grabbing' : wiring ? 'crosshair' : 'grab' }}
|
||||
onMouseDown={handleCanvasMouseDown}
|
||||
onMouseMove={handleCanvasMouseMove}
|
||||
onMouseUp={handleCanvasMouseUp}
|
||||
onWheel={handleWheel}
|
||||
>
|
||||
{/* Grid background */}
|
||||
<svg className="absolute inset-0 w-full h-full" style={{ pointerEvents: 'none' }}>
|
||||
<defs>
|
||||
<pattern id="synth-grid" width={20 * zoom} height={20 * zoom} patternUnits="userSpaceOnUse"
|
||||
x={(camX * zoom) % (20 * zoom)} y={(camY * zoom) % (20 * zoom)}>
|
||||
<circle cx={1} cy={1} r={0.8} fill="oklch(0.3 0 0 / 30%)" />
|
||||
</pattern>
|
||||
</defs>
|
||||
<rect width="100%" height="100%" fill="url(#synth-grid)" />
|
||||
</svg>
|
||||
|
||||
{/* Transformed layer */}
|
||||
<div className="absolute" style={{
|
||||
transform: `translate(${camX * zoom}px, ${camY * zoom}px) scale(${zoom})`,
|
||||
transformOrigin: '0 0',
|
||||
}}>
|
||||
{/* Wire SVG */}
|
||||
<svg className="absolute pointer-events-none" style={{ overflow: 'visible', width: 1, height: 1, zIndex: 3 }}>
|
||||
<g className="pointer-events-auto">{renderWires()}</g>
|
||||
</svg>
|
||||
|
||||
{/* Modules */}
|
||||
{modules.map((mod) => (
|
||||
<SynthModuleNode
|
||||
key={mod.id}
|
||||
module={mod}
|
||||
connections={connections}
|
||||
allModules={modules}
|
||||
audioWrapper={audioNodes.current.get(mod.id) as { _freqSig?: { value: number }; _gateSig?: { value: number } } | undefined}
|
||||
onParamChange={handleParamChange}
|
||||
onDragStart={handleDragStart}
|
||||
onPortMouseDown={handlePortMouseDown}
|
||||
onPortMouseUp={handlePortMouseUp}
|
||||
onDelete={deleteModule}
|
||||
getPortRef={getPortRef}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Palette (floating) */}
|
||||
<div className="absolute left-2 top-2 overflow-y-auto" style={{
|
||||
zIndex: 20, background: COLORS.panel, border: `1px solid ${COLORS.border}`,
|
||||
borderRadius: 8, padding: 8, maxHeight: 'calc(100% - 16px)',
|
||||
boxShadow: '0 8px 32px rgba(0,0,0,0.5)', width: 160,
|
||||
}}>
|
||||
{categories.map((cat) => (
|
||||
<div key={cat} style={{ marginBottom: 8 }}>
|
||||
<div style={{ fontSize: 9, color: COLORS.text2, textTransform: 'uppercase', letterSpacing: '0.5px', marginBottom: 4, paddingLeft: 4 }}>
|
||||
{cat}
|
||||
</div>
|
||||
{MODULE_REGISTRY.filter((m) => m.category === cat).map((m) => (
|
||||
<button key={m.type}
|
||||
onClick={(e) => { e.stopPropagation(); addModule(m.type); }}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 6, width: '100%',
|
||||
padding: '5px 8px', borderRadius: 4, border: 'none',
|
||||
background: 'transparent', color: COLORS.text,
|
||||
fontSize: 11, fontWeight: 500, cursor: 'pointer',
|
||||
textAlign: 'left',
|
||||
}}
|
||||
onMouseEnter={(e) => { if (audioStarted) (e.target as HTMLElement).style.background = COLORS.surface2; }}
|
||||
onMouseLeave={(e) => { (e.target as HTMLElement).style.background = 'transparent'; }}
|
||||
>
|
||||
<span style={{ fontSize: 14, width: 20, textAlign: 'center' }}>{m.icon}</span>
|
||||
{m.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Zoom controls */}
|
||||
<div className="absolute flex flex-col" style={{
|
||||
top: 12, right: 12, zIndex: 50,
|
||||
background: COLORS.surface, border: `1px solid ${COLORS.border}`,
|
||||
borderRadius: 8, boxShadow: '0 2px 8px rgba(0,0,0,0.4)', overflow: 'hidden',
|
||||
}}>
|
||||
<button onClick={() => setZoom(Math.min(3, zoom * 1.25))} style={zoomBtnStyle}>+</button>
|
||||
<button onClick={resetView} style={{ ...zoomBtnStyle, fontSize: 10, height: 26, borderTop: `1px solid ${COLORS.border}`, borderBottom: `1px solid ${COLORS.border}` }}>
|
||||
{Math.round(zoom * 100)}%
|
||||
</button>
|
||||
<button onClick={() => setZoom(Math.max(0.2, zoom / 1.25))} style={zoomBtnStyle}>−</button>
|
||||
</div>
|
||||
|
||||
{/* Empty state */}
|
||||
{modules.length === 0 && (
|
||||
<div className="absolute inset-0 flex items-center justify-center pointer-events-none" style={{ color: COLORS.text2, fontSize: 13, opacity: 0.5 }}>
|
||||
Selecciona un módulo del panel izquierdo para empezar
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Status bar */}
|
||||
<div className="shrink-0 flex items-center gap-4 px-3" style={{ height: 24, background: COLORS.panel, borderTop: `1px solid ${COLORS.border}`, fontSize: 10, color: COLORS.text2, zIndex: 10 }}>
|
||||
<span>Módulos: {modules.length}</span>
|
||||
<span>Conexiones: {connections.length}</span>
|
||||
<span>Zoom: {Math.round(zoom * 100)}%</span>
|
||||
<div className="flex-1" />
|
||||
<span>Scroll: zoom · Click medio: pan · Click en cable: desconectar</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const fullscreenOverlay = fullscreen && createPortal(
|
||||
<div className="fixed inset-0 z-[500] animate-fade-in"
|
||||
onKeyDown={(e) => { if (e.key === 'Escape') { e.stopPropagation(); setFullscreen(false); } }}
|
||||
tabIndex={0}
|
||||
>
|
||||
{synthContent(true)}
|
||||
</div>,
|
||||
document.body
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{synthContent(false)}
|
||||
{fullscreenOverlay}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const zoomBtnStyle: React.CSSProperties = {
|
||||
width: 36, height: 32, fontSize: 18, fontWeight: 600,
|
||||
border: 'none', background: 'transparent', color: COLORS.text,
|
||||
cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
};
|
||||
457
src/components/workbench/modules/synth/SynthModuleNode.tsx
Normal file
457
src/components/workbench/modules/synth/SynthModuleNode.tsx
Normal file
@@ -0,0 +1,457 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useState, useEffect, useRef } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { getModuleDef, PORT_COLORS, type PortDef } from './moduleRegistry';
|
||||
import { SynthModule, SynthConnection } from './synthTypes';
|
||||
import { Knob } from './Knob';
|
||||
|
||||
// Reaktor-matching colors
|
||||
const S = {
|
||||
surface: 'oklch(0.205 0 0)',
|
||||
surface2: 'oklch(0.235 0 0)',
|
||||
border: 'oklch(1 0 0 / 10%)',
|
||||
text: 'oklch(0.985 0 0)',
|
||||
text2: 'oklch(0.556 0 0)',
|
||||
accent: '#6366f1',
|
||||
green: '#22c55e',
|
||||
purple: '#a855f7',
|
||||
};
|
||||
|
||||
const PORT_TO_PARAM: Record<string, Record<string, string>> = {
|
||||
filter: { cutoff: 'frequency' },
|
||||
oscillator: { freq: 'frequency', detune: 'detune' },
|
||||
vca: { cv: 'gain' },
|
||||
};
|
||||
|
||||
function simulateLFO(waveform: string, phase: number): number {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
// Key map for keyboard widget
|
||||
const KEY_MAP: Record<string, number> = {
|
||||
z: 0, s: 1, x: 2, d: 3, c: 4, v: 5, g: 6, b: 7, h: 8, n: 9, j: 10, m: 11,
|
||||
q: 12, '2': 13, w: 14, '3': 15, e: 16, r: 17, '5': 18, t: 19, '6': 20, y: 21, '7': 22, u: 23,
|
||||
};
|
||||
|
||||
function midiToFreq(midi: number): number {
|
||||
return 440 * Math.pow(2, (midi - 69) / 12);
|
||||
}
|
||||
|
||||
interface SynthModuleNodeProps {
|
||||
module: SynthModule;
|
||||
connections: SynthConnection[];
|
||||
allModules: SynthModule[];
|
||||
audioWrapper?: { _freqSig?: { value: number }; _gateSig?: { value: number } };
|
||||
onParamChange: (moduleId: string, param: string, value: unknown) => void;
|
||||
onDragStart: (moduleId: string, offsetX: number, offsetY: number) => void;
|
||||
onPortMouseDown: (moduleId: string, port: string, direction: 'input' | 'output', x: number, y: number) => void;
|
||||
onPortMouseUp: (moduleId: string, port: string, direction: 'input' | 'output', x: number, y: number) => void;
|
||||
onDelete: (moduleId: string) => void;
|
||||
getPortRef: (moduleId: string, port: string, direction: 'input' | 'output') => (el: HTMLDivElement | null) => void;
|
||||
}
|
||||
|
||||
export function SynthModuleNode({
|
||||
module: mod, connections, allModules, audioWrapper,
|
||||
onParamChange, onDragStart, onPortMouseDown, onPortMouseUp, onDelete, getPortRef,
|
||||
}: SynthModuleNodeProps) {
|
||||
const def = getModuleDef(mod.type);
|
||||
if (!def) return null;
|
||||
|
||||
const params = { ...Object.fromEntries(Object.entries(def.params).map(([k, v]) => [k, v.default])), ...mod.params };
|
||||
|
||||
// Find modulated params
|
||||
const modulatedParams = new Set<string>();
|
||||
const portMap = PORT_TO_PARAM[mod.type] || {};
|
||||
for (const conn of connections) {
|
||||
if (conn.to.moduleId === mod.id && portMap[conn.to.port]) {
|
||||
modulatedParams.add(portMap[conn.to.port]);
|
||||
}
|
||||
}
|
||||
|
||||
// Live modulation visualization
|
||||
const [liveValues, setLiveValues] = useState<Record<string, number>>({});
|
||||
const rafRef = useRef<number>(0);
|
||||
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: Record<string, number> = {};
|
||||
for (const conn of connections) {
|
||||
if (conn.to.moduleId !== mod.id) continue;
|
||||
const paramName = portMap[conn.to.port];
|
||||
if (!paramName) continue;
|
||||
const srcMod = allModules.find(m => m.id === conn.from.moduleId);
|
||||
if (!srcMod) continue;
|
||||
const baseValue = params[paramName] as number;
|
||||
const getScale = () => {
|
||||
if (mod.type === 'oscillator' && paramName === 'frequency') return baseValue * 0.5;
|
||||
if (mod.type === 'filter' && paramName === 'frequency') return baseValue;
|
||||
if (mod.type === 'vca' && paramName === 'gain') return 1;
|
||||
return baseValue || 1;
|
||||
};
|
||||
if (srcMod.type === 'lfo') {
|
||||
const srcDef = getModuleDef('lfo')!;
|
||||
const lfoP = { ...Object.fromEntries(Object.entries(srcDef.params).map(([k, v]) => [k, v.default])), ...srcMod.params };
|
||||
const phase = (t * (lfoP.frequency as number)) % 1;
|
||||
const lfoVal = simulateLFO(lfoP.waveform as string, phase) * (lfoP.amplitude as number);
|
||||
newValues[paramName] = baseValue + lfoVal * getScale();
|
||||
} else if (srcMod.type === 'envelope') {
|
||||
const envVal = Math.abs(Math.sin(t * 2)) * 0.8; // visual simulation
|
||||
if (mod.type === 'vca' && paramName === 'gain') newValues[paramName] = envVal;
|
||||
else newValues[paramName] = baseValue + envVal * getScale();
|
||||
} else {
|
||||
const pulseVal = Math.sin(2 * Math.PI * t) * 0.2;
|
||||
newValues[paramName] = baseValue + pulseVal * getScale();
|
||||
}
|
||||
}
|
||||
setLiveValues(newValues);
|
||||
};
|
||||
rafRef.current = requestAnimationFrame(tick);
|
||||
return () => cancelAnimationFrame(rafRef.current);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [mod.id, mod.type, modulatedParams.size, connections.length]);
|
||||
|
||||
const [fullscreen, setFullscreen] = useState(false);
|
||||
|
||||
const handleHeaderDown = useCallback((e: React.MouseEvent) => {
|
||||
if (e.button !== 0) return;
|
||||
e.stopPropagation();
|
||||
const rect = (e.currentTarget as HTMLElement).closest('[data-module]')!.getBoundingClientRect();
|
||||
onDragStart(mod.id, e.clientX - rect.left, e.clientY - rect.top);
|
||||
}, [mod.id, onDragStart]);
|
||||
|
||||
const isConnected = (portName: string, dir: 'input' | 'output') =>
|
||||
connections.some(c => dir === 'input' ? (c.to.moduleId === mod.id && c.to.port === portName) : (c.from.moduleId === mod.id && c.from.port === portName));
|
||||
|
||||
const renderPort = (port: PortDef, dir: 'input' | 'output') => {
|
||||
const color = PORT_COLORS[port.type];
|
||||
const connected = isConnected(port.name, dir);
|
||||
return (
|
||||
<div key={`${dir}-${port.name}`} style={{ display: 'flex', alignItems: 'center', gap: 6, height: 24, flexDirection: dir === 'input' ? 'row' : 'row-reverse' }}>
|
||||
<div
|
||||
ref={getPortRef(mod.id, port.name, dir)}
|
||||
style={{
|
||||
width: 14, height: 14, borderRadius: '50%',
|
||||
border: `2px solid ${color}`,
|
||||
background: connected ? color : `${color}22`,
|
||||
cursor: 'pointer', flexShrink: 0, zIndex: 5,
|
||||
transition: 'transform 0.15s',
|
||||
}}
|
||||
onMouseEnter={(e) => { (e.target as HTMLElement).style.transform = 'scale(1.3)'; }}
|
||||
onMouseLeave={(e) => { (e.target as HTMLElement).style.transform = 'scale(1)'; }}
|
||||
onMouseDown={(e) => {
|
||||
e.stopPropagation();
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
onPortMouseDown(mod.id, port.name, dir, rect.left + rect.width / 2, rect.top + rect.height / 2);
|
||||
}}
|
||||
onMouseUp={(e) => {
|
||||
e.stopPropagation();
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
onPortMouseUp(mod.id, port.name, dir, rect.left + rect.width / 2, rect.top + rect.height / 2);
|
||||
}}
|
||||
/>
|
||||
<span style={{ fontSize: 11, color: S.text2, textTransform: 'uppercase', letterSpacing: '0.3px', whiteSpace: 'nowrap' }}>{port.label}</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Param color by unit
|
||||
const paramColor = (unit?: string) => {
|
||||
if (unit === 'Hz') return S.accent;
|
||||
if (unit === 'dB') return S.green;
|
||||
if (unit === 's') return S.purple;
|
||||
return S.accent;
|
||||
};
|
||||
|
||||
const formatVal = (v: number) => {
|
||||
if (v >= 1000) return `${(v / 1000).toFixed(1)}k`;
|
||||
if (v >= 100) return String(Math.round(v));
|
||||
if (v >= 1) return v.toFixed(1);
|
||||
return v.toFixed(3).replace(/0+$/, '').replace(/\.$/, '');
|
||||
};
|
||||
|
||||
// Keyboard trigger
|
||||
const triggerNote = useCallback((semitone: number, on: boolean) => {
|
||||
if (!audioWrapper) return;
|
||||
const octave = (mod.params.octave as number) ?? 4;
|
||||
const midi = (octave + 1) * 12 + semitone;
|
||||
if (on) {
|
||||
if (audioWrapper._freqSig) audioWrapper._freqSig.value = midiToFreq(midi);
|
||||
if (audioWrapper._gateSig) audioWrapper._gateSig.value = 1;
|
||||
} else {
|
||||
if (audioWrapper._gateSig) audioWrapper._gateSig.value = 0;
|
||||
}
|
||||
}, [audioWrapper, mod.params.octave]);
|
||||
|
||||
// Global keyboard listener for keyboard module
|
||||
const activeKeysRef = useRef(new Set<string>());
|
||||
useEffect(() => {
|
||||
if (mod.type !== 'keyboard') return;
|
||||
const down = (e: KeyboardEvent) => {
|
||||
if (e.repeat) return;
|
||||
const semi = KEY_MAP[e.key.toLowerCase()];
|
||||
if (semi !== undefined && !activeKeysRef.current.has(e.key)) {
|
||||
activeKeysRef.current.add(e.key);
|
||||
triggerNote(semi, true);
|
||||
}
|
||||
};
|
||||
const up = (e: KeyboardEvent) => {
|
||||
const key = e.key.toLowerCase();
|
||||
if (KEY_MAP[key] !== undefined) {
|
||||
activeKeysRef.current.delete(e.key);
|
||||
if (activeKeysRef.current.size === 0) triggerNote(0, false);
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', down);
|
||||
window.addEventListener('keyup', up);
|
||||
return () => { window.removeEventListener('keydown', down); window.removeEventListener('keyup', up); };
|
||||
}, [mod.type, triggerNote]);
|
||||
|
||||
return (
|
||||
<div data-module={mod.id} style={{
|
||||
position: 'absolute', left: mod.x, top: mod.y, userSelect: 'none',
|
||||
width: mod.type === 'sequencer' ? Math.max(200, ((mod.params.steps as number) ?? 8) * 18 + 20) : 200,
|
||||
}}>
|
||||
<div style={{ background: S.surface, border: `1px solid ${S.border}`, borderRadius: 8, boxShadow: '0 4px 16px rgba(0,0,0,0.4)', overflow: 'hidden' }}>
|
||||
{/* Header */}
|
||||
<div style={{ padding: '8px 12px', display: 'flex', alignItems: 'center', gap: 6, background: S.surface2, borderBottom: `1px solid ${S.border}`, cursor: 'grab' }}
|
||||
onMouseDown={handleHeaderDown}>
|
||||
<span style={{ fontSize: 16 }}>{def.icon}</span>
|
||||
<span style={{ fontSize: 12, fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.5px', flex: 1, color: S.text }}>{def.name}</span>
|
||||
{(mod.type === 'keyboard' || mod.type === 'drumpad') && (
|
||||
<button onClick={(e) => { e.stopPropagation(); setFullscreen(true); }}
|
||||
style={{ width: 18, height: 18, border: 'none', background: 'transparent', color: S.text2, cursor: 'pointer', fontSize: 13 }}>⤢</button>
|
||||
)}
|
||||
<button onClick={(e) => { e.stopPropagation(); onDelete(mod.id); }}
|
||||
style={{ width: 18, height: 18, border: 'none', background: 'transparent', color: S.text2, cursor: 'pointer', fontSize: 13 }}>✕</button>
|
||||
</div>
|
||||
|
||||
<div style={{ padding: '10px 12px', display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
{/* Input ports */}
|
||||
{def.inputs.map(p => renderPort(p, 'input'))}
|
||||
|
||||
{/* Parameters — horizontal rows: label | knob | value */}
|
||||
{Object.entries(def.params).map(([name, paramDef]) => {
|
||||
const pColor = paramColor(paramDef.type === 'knob' ? paramDef.unit : undefined);
|
||||
const live = liveValues[name];
|
||||
const displayVal = live !== undefined ? live : params[name] as number;
|
||||
|
||||
if (paramDef.type === 'knob') {
|
||||
return (
|
||||
<div key={name} style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<span style={{ fontSize: 11, color: S.text2, width: 50, flexShrink: 0 }}>{paramDef.label || name}</span>
|
||||
<Knob value={params[name] as number} min={paramDef.min} max={paramDef.max}
|
||||
onChange={v => onParamChange(mod.id, name, v)}
|
||||
color={pColor} modulated={modulatedParams.has(name)} liveValue={live} />
|
||||
<span style={{
|
||||
fontSize: 10, fontFamily: "'JetBrains Mono', monospace", color: pColor,
|
||||
textShadow: live !== undefined ? `0 0 6px ${pColor}60` : undefined,
|
||||
}}>
|
||||
{formatVal(displayVal)}{paramDef.unit ? ` ${paramDef.unit}` : ''}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (paramDef.type === 'select') {
|
||||
return (
|
||||
<div key={name} style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<span style={{ fontSize: 11, color: S.text2, width: 50, flexShrink: 0 }}>{paramDef.label || name}</span>
|
||||
<select value={params[name] as string} onChange={e => onParamChange(mod.id, name, e.target.value)}
|
||||
onMouseDown={e => e.stopPropagation()}
|
||||
style={{ background: 'oklch(0.145 0 0)', border: `1px solid ${S.border}`, borderRadius: 3, fontSize: 10, padding: '2px 4px', color: S.text, outline: 'none', flex: 1 }}>
|
||||
{paramDef.options.map(opt => <option key={opt} value={opt}>{opt}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
})}
|
||||
|
||||
{/* Keyboard widget */}
|
||||
{mod.type === 'keyboard' && (
|
||||
<div style={{ padding: '2px 0' }} onMouseDown={e => e.stopPropagation()}>
|
||||
<svg viewBox="0 0 140 30" style={{ width: '100%', height: 30 }}>
|
||||
{[0, 2, 4, 5, 7, 9, 11].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={() => triggerNote(note, true)} onPointerUp={() => triggerNote(note, false)} />
|
||||
))}
|
||||
{[1, 3, -1, 6, 8, 10].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={() => triggerNote(note, true)} onPointerUp={() => triggerNote(note, false)} />
|
||||
);
|
||||
})}
|
||||
</svg>
|
||||
<div style={{ fontSize: 9, color: S.text2, textAlign: 'center', marginTop: 2 }}>
|
||||
Z-M / Q-I keys · Oct {(mod.params.octave as number) ?? 4}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Drum Pad widget — 4x4 grid, 16 pads matching reaktor */}
|
||||
{mod.type === 'drumpad' && (() => {
|
||||
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' },
|
||||
];
|
||||
return (
|
||||
<div onMouseDown={e => e.stopPropagation()}>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: 3 }}>
|
||||
{PAD_NOTES.map((pad, i) => (
|
||||
<div key={i} style={{
|
||||
background: `${pad.color}15`, border: `1px solid ${pad.color}60`,
|
||||
borderRadius: 4, cursor: 'pointer', padding: '4px 2px', textAlign: 'center',
|
||||
fontSize: 8, color: pad.color, fontWeight: 600, userSelect: 'none',
|
||||
}}
|
||||
onPointerDown={(e) => {
|
||||
e.stopPropagation();
|
||||
if (audioWrapper?._freqSig) audioWrapper._freqSig.value = midiToFreq(pad.note);
|
||||
if (audioWrapper?._gateSig) audioWrapper._gateSig.value = 1;
|
||||
(e.currentTarget as HTMLElement).style.background = pad.color;
|
||||
(e.currentTarget as HTMLElement).style.color = '#000';
|
||||
setTimeout(() => {
|
||||
if (audioWrapper?._gateSig) audioWrapper._gateSig.value = 0;
|
||||
(e.currentTarget as HTMLElement).style.background = `${pad.color}15`;
|
||||
(e.currentTarget as HTMLElement).style.color = pad.color;
|
||||
}, 150);
|
||||
}}
|
||||
>
|
||||
{pad.label}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div style={{ fontSize: 9, color: S.text2, textAlign: 'center', marginTop: 2 }}>Tap pads to trigger</div>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* Sequencer widget — SVG bar grid with pitch/gate */}
|
||||
{mod.type === 'sequencer' && (() => {
|
||||
const NOTE_NAMES = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B'];
|
||||
const noteLabel = (midi: number) => NOTE_NAMES[midi % 12] + Math.floor(midi / 12 - 1);
|
||||
const numSteps = (mod.params.steps as number) ?? 8;
|
||||
|
||||
// Init steps if needed
|
||||
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 },
|
||||
];
|
||||
let steps = (mod.params._steps as { midi: number; gate: boolean }[]) || DEFAULT_STEPS.slice(0, numSteps);
|
||||
while (steps.length < numSteps) steps = [...steps, { midi: 60, gate: false }];
|
||||
steps = steps.slice(0, numSteps);
|
||||
|
||||
const CELL_W = 18;
|
||||
const CELL_H = 50;
|
||||
const W = CELL_W * numSteps;
|
||||
const H = CELL_H + 16;
|
||||
|
||||
const toggleGate = (idx: number) => {
|
||||
const newSteps = [...steps];
|
||||
newSteps[idx] = { ...newSteps[idx], gate: !newSteps[idx].gate };
|
||||
onParamChange(mod.id, '_steps', newSteps);
|
||||
};
|
||||
const changeNote = (idx: number, delta: number) => {
|
||||
const newSteps = [...steps];
|
||||
newSteps[idx] = { ...newSteps[idx], midi: Math.max(36, Math.min(96, newSteps[idx].midi + delta)) };
|
||||
onParamChange(mod.id, '_steps', newSteps);
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ width: W + 4, overflow: 'hidden' }} onMouseDown={e => e.stopPropagation()}>
|
||||
<svg viewBox={`0 0 ${W} ${H}`} style={{ width: W, height: H, display: 'block' }}>
|
||||
{steps.map((s, i) => {
|
||||
const x = i * CELL_W;
|
||||
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="#0c0c18" stroke="#222" strokeWidth={0.5} />
|
||||
{s.gate && (
|
||||
<rect x={x + 3} y={CELL_H - barHeight - 2} width={CELL_W - 6} height={barHeight}
|
||||
rx={1} fill="#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>
|
||||
{/* Click zones: top=pitch up, mid=toggle, bottom=pitch down */}
|
||||
<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>
|
||||
);
|
||||
})}
|
||||
</svg>
|
||||
<div style={{ fontSize: 8, color: '#555', textAlign: 'center', marginTop: 2 }}>
|
||||
↑top/↓bot: pitch · mid: toggle
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* Output ports */}
|
||||
{def.outputs.map(p => renderPort(p, 'output'))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Fullscreen keyboard portal */}
|
||||
{fullscreen && mod.type === 'keyboard' && createPortal(
|
||||
<div className="fixed inset-0 z-[500] animate-fade-in" style={{ background: '#050510', display: 'flex', flexDirection: 'column' }}
|
||||
onKeyDown={e => { if (e.key === 'Escape') setFullscreen(false); }}>
|
||||
<div style={{ padding: '12px 16px', display: 'flex', alignItems: 'center', gap: 12, borderBottom: `1px solid ${S.border}` }}>
|
||||
<button onClick={() => setFullscreen(false)} style={{ border: 'none', background: 'transparent', color: S.text, cursor: 'pointer', fontSize: 16 }}>✕</button>
|
||||
<span style={{ color: S.text2, fontSize: 12 }}>Oct {(mod.params.octave as number) ?? 4}</span>
|
||||
</div>
|
||||
<div style={{ flex: 1, display: 'flex', position: 'relative' }}>
|
||||
{[0, 2, 4, 5, 7, 9, 11].map((note, i) => (
|
||||
<div key={`w${i}`} style={{ flex: 1, background: 'linear-gradient(180deg, #2a2a4a, #1a1a35)', border: '1px solid #333', cursor: 'pointer', display: 'flex', alignItems: 'flex-end', justifyContent: 'center', paddingBottom: 20 }}
|
||||
onPointerDown={() => triggerNote(note, true)} onPointerUp={() => triggerNote(note, false)} onPointerLeave={() => triggerNote(note, false)}>
|
||||
<span style={{ fontSize: 12, color: S.text2 }}>{['C', 'D', 'E', 'F', 'G', 'A', 'B'][i]}</span>
|
||||
</div>
|
||||
))}
|
||||
{[{ note: 1, after: 0 }, { note: 3, after: 1 }, { note: 6, after: 3 }, { note: 8, after: 4 }, { note: 10, after: 5 }].map(k => (
|
||||
<div key={`b${k.note}`} style={{
|
||||
position: 'absolute', left: `${(k.after + 0.65) * (100 / 7)}%`, width: `${(100 / 7) * 0.65}%`,
|
||||
height: '58%', top: 0, background: 'linear-gradient(180deg, #111, #1a1a2e)', border: '1px solid #222',
|
||||
cursor: 'pointer', borderRadius: '0 0 4px 4px', zIndex: 1,
|
||||
}}
|
||||
onPointerDown={() => triggerNote(k.note, true)} onPointerUp={() => triggerNote(k.note, false)} onPointerLeave={() => triggerNote(k.note, false)} />
|
||||
))}
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
385
src/components/workbench/modules/synth/audioEngine.ts
Normal file
385
src/components/workbench/modules/synth/audioEngine.ts
Normal file
@@ -0,0 +1,385 @@
|
||||
import * as Tone from 'tone';
|
||||
|
||||
export interface AudioNodeWrapper {
|
||||
node: Tone.ToneAudioNode | null;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
inputs: Record<string, any>;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
outputs: Record<string, any>;
|
||||
dispose: () => void;
|
||||
}
|
||||
|
||||
export function createAudioNode(
|
||||
type: string,
|
||||
params: Record<string, unknown>
|
||||
): AudioNodeWrapper {
|
||||
switch (type) {
|
||||
case 'oscillator': {
|
||||
const freq = (params.frequency as number) || 440;
|
||||
const osc = new Tone.Oscillator({
|
||||
type: (params.waveform as OscillatorType) || 'sawtooth',
|
||||
frequency: freq,
|
||||
detune: (params.detune as number) || 0,
|
||||
}).start();
|
||||
// Freq modulation scaler: LFO (-1..1) → Gain(freq*0.5) → osc.frequency
|
||||
// This makes LFO sweep ±50% of the base frequency
|
||||
const freqMod = new Tone.Gain(freq * 0.5);
|
||||
freqMod.connect(osc.frequency);
|
||||
return {
|
||||
node: osc,
|
||||
inputs: { freq: freqMod },
|
||||
outputs: { out: osc },
|
||||
dispose: () => { osc.stop(); freqMod.disconnect(); freqMod.dispose(); osc.dispose(); },
|
||||
};
|
||||
}
|
||||
|
||||
case 'lfo': {
|
||||
const lfo = new Tone.LFO({
|
||||
type: (params.waveform as OscillatorType) || 'sine',
|
||||
frequency: (params.frequency as number) || 2,
|
||||
amplitude: (params.amplitude as number) || 0.5,
|
||||
min: -1,
|
||||
max: 1,
|
||||
}).start();
|
||||
return {
|
||||
node: lfo,
|
||||
inputs: {},
|
||||
outputs: { out: lfo },
|
||||
dispose: () => { lfo.stop(); lfo.dispose(); },
|
||||
};
|
||||
}
|
||||
|
||||
case 'noise': {
|
||||
const noise = new Tone.Noise((params.noiseType as 'white' | 'pink' | 'brown') || 'white').start();
|
||||
return {
|
||||
node: noise,
|
||||
inputs: {},
|
||||
outputs: { out: noise },
|
||||
dispose: () => { noise.stop(); noise.dispose(); },
|
||||
};
|
||||
}
|
||||
|
||||
case 'keyboard': {
|
||||
// Outputs constant freq signal + gate signal
|
||||
// Controlled externally via setFreq/setGate methods
|
||||
const freqSig = new Tone.Signal(440);
|
||||
const gateSig = new Tone.Signal(0);
|
||||
return {
|
||||
node: null,
|
||||
inputs: {},
|
||||
outputs: { freq: freqSig, gate: gateSig },
|
||||
dispose: () => { freqSig.dispose(); gateSig.dispose(); },
|
||||
_freqSig: freqSig,
|
||||
_gateSig: gateSig,
|
||||
} as AudioNodeWrapper;
|
||||
}
|
||||
|
||||
case 'sequencer': {
|
||||
const freqSig = new Tone.Signal(440);
|
||||
const gateSig = new Tone.Signal(0);
|
||||
const bpm = (params.bpm as number) || 120;
|
||||
const steps = (params.steps as number) || 8;
|
||||
// Default sequence: C major scale fragment
|
||||
const noteSequence = [60, 62, 64, 65, 67, 69, 71, 72, 72, 71, 69, 67, 65, 64, 62, 60];
|
||||
let stepIndex = 0;
|
||||
let intervalId: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
const msPerStep = (60 / bpm / 4) * 1000; // 16th notes
|
||||
intervalId = setInterval(() => {
|
||||
const midi = noteSequence[stepIndex % noteSequence.length];
|
||||
const freq = 440 * Math.pow(2, (midi - 69) / 12);
|
||||
freqSig.value = freq;
|
||||
gateSig.value = 1;
|
||||
setTimeout(() => { gateSig.value = 0; }, msPerStep * 0.8);
|
||||
stepIndex = (stepIndex + 1) % steps;
|
||||
}, msPerStep);
|
||||
|
||||
return {
|
||||
node: null,
|
||||
inputs: {},
|
||||
outputs: { freq: freqSig, gate: gateSig },
|
||||
dispose: () => {
|
||||
if (intervalId) clearInterval(intervalId);
|
||||
freqSig.dispose(); gateSig.dispose();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
case 'drumpad': {
|
||||
const gateSig = new Tone.Signal(0);
|
||||
return {
|
||||
node: null,
|
||||
inputs: {},
|
||||
outputs: { gate: gateSig },
|
||||
dispose: () => { gateSig.dispose(); },
|
||||
_gateSig: gateSig,
|
||||
} as AudioNodeWrapper;
|
||||
}
|
||||
|
||||
case 'scope': {
|
||||
const analyser = new Tone.Analyser('waveform', 2048);
|
||||
return {
|
||||
node: analyser,
|
||||
inputs: { in: analyser },
|
||||
outputs: {},
|
||||
dispose: () => { analyser.dispose(); },
|
||||
_analyser: analyser,
|
||||
} as AudioNodeWrapper;
|
||||
}
|
||||
|
||||
case 'filter': {
|
||||
const freq = (params.frequency as number) || 1000;
|
||||
const filter = new Tone.Filter({
|
||||
type: (params.filterType as BiquadFilterType) || 'lowpass',
|
||||
frequency: freq,
|
||||
Q: (params.Q as number) || 1,
|
||||
});
|
||||
// Cutoff modulation scaler: LFO (-1..1) → Gain(freq) → filter.frequency
|
||||
const cutoffMod = new Tone.Gain(freq);
|
||||
cutoffMod.connect(filter.frequency);
|
||||
return {
|
||||
node: filter,
|
||||
inputs: { in: filter, cutoff: cutoffMod },
|
||||
outputs: { out: filter },
|
||||
dispose: () => { cutoffMod.disconnect(); cutoffMod.dispose(); filter.dispose(); },
|
||||
};
|
||||
}
|
||||
|
||||
case 'envelope': {
|
||||
const env = new Tone.Envelope({
|
||||
attack: (params.attack as number) || 0.01,
|
||||
decay: (params.decay as number) || 0.2,
|
||||
sustain: (params.sustain as number) || 0.5,
|
||||
release: (params.release as number) || 0.3,
|
||||
});
|
||||
// Use a constant signal of 1 shaped by the envelope
|
||||
// Envelope scales a signal from 0 to 1
|
||||
const constantOne = new Tone.Signal(1);
|
||||
const envGain = new Tone.Gain(0);
|
||||
constantOne.connect(envGain);
|
||||
env.connect(envGain.gain);
|
||||
// Auto-trigger the envelope (will sustain until release)
|
||||
env.triggerAttack();
|
||||
return {
|
||||
node: env as unknown as Tone.ToneAudioNode,
|
||||
inputs: {},
|
||||
outputs: { out: envGain },
|
||||
dispose: () => { env.triggerRelease(); env.dispose(); constantOne.dispose(); envGain.dispose(); },
|
||||
_trigger: (on: boolean) => { if (on) env.triggerAttack(); else env.triggerRelease(); },
|
||||
} as AudioNodeWrapper;
|
||||
}
|
||||
|
||||
case 'vca': {
|
||||
// VCA: when CV is connected, it should control the gain from 0 to 1
|
||||
const gain = new Tone.Gain(0); // start at 0 so CV (envelope) controls it
|
||||
const baseGainVal = (params.gain as number) ?? 1;
|
||||
// If no CV connected, we want baseGain. We'll set it after.
|
||||
// The CV input modulates gain.gain directly (0..1 range from envelope)
|
||||
gain.gain.value = baseGainVal;
|
||||
return {
|
||||
node: gain,
|
||||
inputs: { in: gain, cv: gain.gain },
|
||||
outputs: { out: gain },
|
||||
dispose: () => gain.dispose(),
|
||||
};
|
||||
}
|
||||
|
||||
case 'mixer': {
|
||||
const g1 = new Tone.Gain((params.gain1 as number) ?? 0.5);
|
||||
const g2 = new Tone.Gain((params.gain2 as number) ?? 0.5);
|
||||
const g3 = new Tone.Gain((params.gain3 as number) ?? 0.5);
|
||||
const sum = new Tone.Gain(1);
|
||||
g1.connect(sum);
|
||||
g2.connect(sum);
|
||||
g3.connect(sum);
|
||||
return {
|
||||
node: sum,
|
||||
inputs: { in1: g1, in2: g2, in3: g3 },
|
||||
outputs: { out: sum },
|
||||
dispose: () => { g1.dispose(); g2.dispose(); g3.dispose(); sum.dispose(); },
|
||||
};
|
||||
}
|
||||
|
||||
case 'delay': {
|
||||
const delay = new Tone.FeedbackDelay({
|
||||
delayTime: (params.delayTime as number) || 0.3,
|
||||
feedback: (params.feedback as number) || 0.4,
|
||||
wet: (params.wet as number) || 0.3,
|
||||
});
|
||||
return {
|
||||
node: delay,
|
||||
inputs: { in: delay },
|
||||
outputs: { out: delay },
|
||||
dispose: () => delay.dispose(),
|
||||
};
|
||||
}
|
||||
|
||||
case 'reverb': {
|
||||
const reverb = new Tone.Reverb({
|
||||
decay: (params.decay as number) || 2,
|
||||
});
|
||||
reverb.wet.value = (params.wet as number) || 0.3;
|
||||
return {
|
||||
node: reverb,
|
||||
inputs: { in: reverb },
|
||||
outputs: { out: reverb },
|
||||
dispose: () => reverb.dispose(),
|
||||
};
|
||||
}
|
||||
|
||||
case 'distortion': {
|
||||
const dist = new Tone.Distortion({
|
||||
distortion: (params.distortion as number) || 0.4,
|
||||
});
|
||||
dist.wet.value = (params.wet as number) || 0.5;
|
||||
return {
|
||||
node: dist,
|
||||
inputs: { in: dist },
|
||||
outputs: { out: dist },
|
||||
dispose: () => dist.dispose(),
|
||||
};
|
||||
}
|
||||
|
||||
case 'output': {
|
||||
const leftGain = new Tone.Gain(1);
|
||||
const rightGain = new Tone.Gain(1);
|
||||
const merge = new Tone.Merge();
|
||||
const masterVol = new Tone.Gain(Tone.dbToGain((params.volume as number) || -12));
|
||||
leftGain.connect(merge, 0, 0); // left channel
|
||||
rightGain.connect(merge, 0, 1); // right channel
|
||||
merge.connect(masterVol);
|
||||
masterVol.connect(Tone.getDestination());
|
||||
return {
|
||||
node: masterVol,
|
||||
inputs: { left: leftGain, right: rightGain },
|
||||
outputs: {},
|
||||
dispose: () => {
|
||||
leftGain.disconnect(); rightGain.disconnect();
|
||||
merge.disconnect(); masterVol.disconnect();
|
||||
leftGain.dispose(); rightGain.dispose();
|
||||
merge.dispose(); masterVol.dispose();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
default:
|
||||
return { node: null, inputs: {}, outputs: {}, dispose: () => {} };
|
||||
}
|
||||
}
|
||||
|
||||
export function updateAudioParam(
|
||||
wrapper: AudioNodeWrapper,
|
||||
type: string,
|
||||
paramName: string,
|
||||
value: unknown
|
||||
) {
|
||||
if (!wrapper.node) return;
|
||||
|
||||
switch (type) {
|
||||
case 'oscillator': {
|
||||
const osc = wrapper.node as Tone.Oscillator;
|
||||
if (paramName === 'waveform') osc.type = value as OscillatorType;
|
||||
if (paramName === 'frequency') osc.frequency.value = value as number;
|
||||
if (paramName === 'detune') osc.detune.value = value as number;
|
||||
break;
|
||||
}
|
||||
case 'lfo': {
|
||||
const lfo = wrapper.node as Tone.LFO;
|
||||
if (paramName === 'waveform') lfo.type = value as OscillatorType;
|
||||
if (paramName === 'frequency') lfo.frequency.value = value as number;
|
||||
if (paramName === 'amplitude') lfo.amplitude.value = value as number;
|
||||
break;
|
||||
}
|
||||
case 'noise': {
|
||||
const noise = wrapper.node as Tone.Noise;
|
||||
if (paramName === 'noiseType') noise.type = value as 'white' | 'pink' | 'brown';
|
||||
break;
|
||||
}
|
||||
case 'filter': {
|
||||
const filter = wrapper.node as Tone.Filter;
|
||||
if (paramName === 'filterType') filter.type = value as BiquadFilterType;
|
||||
if (paramName === 'frequency') filter.frequency.value = value as number;
|
||||
if (paramName === 'Q') filter.Q.value = value as number;
|
||||
break;
|
||||
}
|
||||
case 'envelope': {
|
||||
const env = wrapper.node as unknown as Tone.Envelope;
|
||||
if (paramName === 'attack') env.attack = value as number;
|
||||
if (paramName === 'decay') env.decay = value as number;
|
||||
if (paramName === 'sustain') env.sustain = value as number;
|
||||
if (paramName === 'release') env.release = value as number;
|
||||
break;
|
||||
}
|
||||
case 'vca': {
|
||||
const gain = wrapper.node as Tone.Gain;
|
||||
if (paramName === 'gain') gain.gain.value = value as number;
|
||||
break;
|
||||
}
|
||||
case 'delay': {
|
||||
const delay = wrapper.node as Tone.FeedbackDelay;
|
||||
if (paramName === 'delayTime') delay.delayTime.value = value as number;
|
||||
if (paramName === 'feedback') delay.feedback.value = value as number;
|
||||
if (paramName === 'wet') delay.wet.value = value as number;
|
||||
break;
|
||||
}
|
||||
case 'reverb': {
|
||||
const rev = wrapper.node as Tone.Reverb;
|
||||
if (paramName === 'decay') rev.decay = value as number;
|
||||
if (paramName === 'wet') rev.wet.value = value as number;
|
||||
break;
|
||||
}
|
||||
case 'distortion': {
|
||||
const dist = wrapper.node as Tone.Distortion;
|
||||
if (paramName === 'distortion') dist.distortion = value as number;
|
||||
if (paramName === 'wet') dist.wet.value = value as number;
|
||||
break;
|
||||
}
|
||||
case 'output': {
|
||||
const vol = wrapper.node as Tone.Gain;
|
||||
if (paramName === 'volume') vol.gain.value = Tone.dbToGain(value as number);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function connectAudio(
|
||||
fromWrapper: AudioNodeWrapper,
|
||||
fromPort: string,
|
||||
toWrapper: AudioNodeWrapper,
|
||||
toPort: string
|
||||
) {
|
||||
const output = fromWrapper.outputs[fromPort];
|
||||
const input = toWrapper.inputs[toPort];
|
||||
if (output && input) {
|
||||
try {
|
||||
// If connecting CV to VCA, zero the base gain so envelope controls it
|
||||
if (toPort === 'cv' && toWrapper.node && 'gain' in toWrapper.node) {
|
||||
(toWrapper.node as Tone.Gain).gain.value = 0;
|
||||
}
|
||||
output.connect(input);
|
||||
} catch (e) {
|
||||
console.warn('Audio connect failed:', e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function disconnectAudio(
|
||||
fromWrapper: AudioNodeWrapper,
|
||||
fromPort: string,
|
||||
toWrapper: AudioNodeWrapper,
|
||||
toPort: string
|
||||
) {
|
||||
const output = fromWrapper.outputs[fromPort];
|
||||
const input = toWrapper.inputs[toPort];
|
||||
if (output && input) {
|
||||
try {
|
||||
output.disconnect(input);
|
||||
} catch {
|
||||
// Already disconnected
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function startAudioContext() {
|
||||
await Tone.start();
|
||||
}
|
||||
297
src/components/workbench/modules/synth/moduleRegistry.ts
Normal file
297
src/components/workbench/modules/synth/moduleRegistry.ts
Normal file
@@ -0,0 +1,297 @@
|
||||
// === Module type definitions for the modular synth ===
|
||||
|
||||
export type PortType = 'audio' | 'control' | 'trigger';
|
||||
export type PortDirection = 'input' | 'output';
|
||||
|
||||
export interface PortDef {
|
||||
name: string;
|
||||
type: PortType;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export type ParamType = 'knob' | 'select';
|
||||
|
||||
export interface KnobParam {
|
||||
type: 'knob';
|
||||
min: number;
|
||||
max: number;
|
||||
default: number;
|
||||
step?: number;
|
||||
unit?: string;
|
||||
label?: string;
|
||||
}
|
||||
|
||||
export interface SelectParam {
|
||||
type: 'select';
|
||||
options: string[];
|
||||
default: string;
|
||||
label?: string;
|
||||
}
|
||||
|
||||
export type ParamDef = KnobParam | SelectParam;
|
||||
|
||||
export interface ModuleDef {
|
||||
type: string;
|
||||
name: string;
|
||||
icon: string;
|
||||
category: 'source' | 'filter' | 'modulation' | 'effect' | 'utility' | 'output';
|
||||
inputs: PortDef[];
|
||||
outputs: PortDef[];
|
||||
params: Record<string, ParamDef>;
|
||||
}
|
||||
|
||||
export const PORT_COLORS: Record<PortType, string> = {
|
||||
audio: '#00e5ff',
|
||||
control: '#ff6644',
|
||||
trigger: '#ffcc00',
|
||||
};
|
||||
|
||||
export const MODULE_REGISTRY: ModuleDef[] = [
|
||||
// === SOURCES ===
|
||||
{
|
||||
type: 'oscillator',
|
||||
name: 'Oscillator',
|
||||
icon: '~',
|
||||
category: 'source',
|
||||
inputs: [
|
||||
{ name: 'freq', type: 'audio', label: 'Freq' },
|
||||
],
|
||||
outputs: [
|
||||
{ name: 'out', type: 'audio', label: 'Out' },
|
||||
],
|
||||
params: {
|
||||
waveform: { type: 'select', options: ['sine', 'square', 'sawtooth', 'triangle'], default: 'sawtooth' },
|
||||
frequency: { type: 'knob', min: 20, max: 8000, default: 440, unit: 'Hz' },
|
||||
detune: { type: 'knob', min: -1200, max: 1200, default: 0, unit: 'ct' },
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'lfo',
|
||||
name: 'LFO',
|
||||
icon: '∿',
|
||||
category: 'source',
|
||||
inputs: [],
|
||||
outputs: [
|
||||
{ name: 'out', type: 'control', label: 'Out' },
|
||||
],
|
||||
params: {
|
||||
waveform: { type: 'select', options: ['sine', 'square', 'sawtooth', 'triangle'], default: 'sine' },
|
||||
frequency: { type: 'knob', min: 0.1, max: 30, default: 2, step: 0.1, unit: 'Hz' },
|
||||
amplitude: { type: 'knob', min: 0, max: 1, default: 0.5, step: 0.01 },
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'noise',
|
||||
name: 'Noise',
|
||||
icon: '▓',
|
||||
category: 'source',
|
||||
inputs: [],
|
||||
outputs: [
|
||||
{ name: 'out', type: 'audio', label: 'Out' },
|
||||
],
|
||||
params: {
|
||||
noiseType: { type: 'select', options: ['white', 'pink', 'brown'], default: 'white' },
|
||||
},
|
||||
},
|
||||
|
||||
// === INPUT / CONTROLLERS ===
|
||||
{
|
||||
type: 'keyboard',
|
||||
name: 'Keyboard',
|
||||
icon: '🎹',
|
||||
category: 'source',
|
||||
inputs: [],
|
||||
outputs: [
|
||||
{ name: 'freq', type: 'audio', label: 'Freq' },
|
||||
{ name: 'gate', type: 'trigger', label: 'Gate' },
|
||||
],
|
||||
params: {
|
||||
octave: { type: 'knob', min: 1, max: 7, default: 4, step: 1 },
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'sequencer',
|
||||
name: 'Sequencer',
|
||||
icon: '▦',
|
||||
category: 'source',
|
||||
inputs: [],
|
||||
outputs: [
|
||||
{ name: 'freq', type: 'audio', label: 'Freq' },
|
||||
{ name: 'gate', type: 'trigger', label: 'Gate' },
|
||||
],
|
||||
params: {
|
||||
bpm: { type: 'knob', min: 40, max: 300, default: 120, step: 1, unit: 'bpm' },
|
||||
steps: { type: 'knob', min: 4, max: 16, default: 8, step: 1 },
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'drumpad',
|
||||
name: 'Drum Pad',
|
||||
icon: '🥁',
|
||||
category: 'source',
|
||||
inputs: [],
|
||||
outputs: [
|
||||
{ name: 'gate', type: 'trigger', label: 'Gate' },
|
||||
],
|
||||
params: {},
|
||||
},
|
||||
{
|
||||
type: 'scope',
|
||||
name: 'Scope',
|
||||
icon: '📊',
|
||||
category: 'utility',
|
||||
inputs: [
|
||||
{ name: 'in', type: 'audio', label: 'In' },
|
||||
],
|
||||
outputs: [],
|
||||
params: {},
|
||||
},
|
||||
|
||||
// === FILTER ===
|
||||
{
|
||||
type: 'filter',
|
||||
name: 'Filter',
|
||||
icon: '▼',
|
||||
category: 'filter',
|
||||
inputs: [
|
||||
{ name: 'in', type: 'audio', label: 'In' },
|
||||
{ name: 'cutoff', type: 'control', label: 'Cut' },
|
||||
],
|
||||
outputs: [
|
||||
{ name: 'out', type: 'audio', label: 'Out' },
|
||||
],
|
||||
params: {
|
||||
filterType: { type: 'select', options: ['lowpass', 'highpass', 'bandpass', 'notch'], default: 'lowpass' },
|
||||
frequency: { type: 'knob', min: 20, max: 12000, default: 1000, unit: 'Hz' },
|
||||
Q: { type: 'knob', min: 0.1, max: 20, default: 1, step: 0.1 },
|
||||
},
|
||||
},
|
||||
|
||||
// === MODULATION ===
|
||||
{
|
||||
type: 'envelope',
|
||||
name: 'Envelope',
|
||||
icon: '⌇',
|
||||
category: 'modulation',
|
||||
inputs: [
|
||||
{ name: 'gate', type: 'trigger', label: 'Gate' },
|
||||
],
|
||||
outputs: [
|
||||
{ name: 'out', type: 'control', label: 'Out' },
|
||||
],
|
||||
params: {
|
||||
attack: { type: 'knob', min: 0.001, max: 2, default: 0.01, step: 0.001, unit: 's' },
|
||||
decay: { type: 'knob', min: 0.001, max: 2, default: 0.2, step: 0.001, unit: 's' },
|
||||
sustain: { type: 'knob', min: 0, max: 1, default: 0.5, step: 0.01 },
|
||||
release: { type: 'knob', min: 0.001, max: 5, default: 0.3, step: 0.001, unit: 's' },
|
||||
},
|
||||
},
|
||||
|
||||
// === UTILITY ===
|
||||
{
|
||||
type: 'vca',
|
||||
name: 'VCA',
|
||||
icon: '▷',
|
||||
category: 'utility',
|
||||
inputs: [
|
||||
{ name: 'in', type: 'audio', label: 'In' },
|
||||
{ name: 'cv', type: 'control', label: 'CV' },
|
||||
],
|
||||
outputs: [
|
||||
{ name: 'out', type: 'audio', label: 'Out' },
|
||||
],
|
||||
params: {
|
||||
gain: { type: 'knob', min: 0, max: 1, default: 1, step: 0.01 },
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'mixer',
|
||||
name: 'Mixer',
|
||||
icon: '⊕',
|
||||
category: 'utility',
|
||||
inputs: [
|
||||
{ name: 'in1', type: 'audio', label: 'In 1' },
|
||||
{ name: 'in2', type: 'audio', label: 'In 2' },
|
||||
{ name: 'in3', type: 'audio', label: 'In 3' },
|
||||
],
|
||||
outputs: [
|
||||
{ name: 'out', type: 'audio', label: 'Out' },
|
||||
],
|
||||
params: {
|
||||
gain1: { type: 'knob', min: 0, max: 1, default: 0.5, step: 0.01 },
|
||||
gain2: { type: 'knob', min: 0, max: 1, default: 0.5, step: 0.01 },
|
||||
gain3: { type: 'knob', min: 0, max: 1, default: 0.5, step: 0.01 },
|
||||
},
|
||||
},
|
||||
|
||||
// === EFFECTS ===
|
||||
{
|
||||
type: 'delay',
|
||||
name: 'Delay',
|
||||
icon: '⧖',
|
||||
category: 'effect',
|
||||
inputs: [
|
||||
{ name: 'in', type: 'audio', label: 'In' },
|
||||
],
|
||||
outputs: [
|
||||
{ name: 'out', type: 'audio', label: 'Out' },
|
||||
],
|
||||
params: {
|
||||
delayTime: { type: 'knob', min: 0.01, max: 1, default: 0.3, step: 0.01, unit: 's' },
|
||||
feedback: { type: 'knob', min: 0, max: 0.95, default: 0.4, step: 0.01 },
|
||||
wet: { type: 'knob', min: 0, max: 1, default: 0.3, step: 0.01 },
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'reverb',
|
||||
name: 'Reverb',
|
||||
icon: '≈',
|
||||
category: 'effect',
|
||||
inputs: [
|
||||
{ name: 'in', type: 'audio', label: 'In' },
|
||||
],
|
||||
outputs: [
|
||||
{ name: 'out', type: 'audio', label: 'Out' },
|
||||
],
|
||||
params: {
|
||||
decay: { type: 'knob', min: 0.1, max: 10, default: 2, step: 0.1, unit: 's' },
|
||||
wet: { type: 'knob', min: 0, max: 1, default: 0.3, step: 0.01 },
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'distortion',
|
||||
name: 'Distortion',
|
||||
icon: '⚡',
|
||||
category: 'effect',
|
||||
inputs: [
|
||||
{ name: 'in', type: 'audio', label: 'In' },
|
||||
],
|
||||
outputs: [
|
||||
{ name: 'out', type: 'audio', label: 'Out' },
|
||||
],
|
||||
params: {
|
||||
distortion: { type: 'knob', min: 0, max: 1, default: 0.4, step: 0.01 },
|
||||
wet: { type: 'knob', min: 0, max: 1, default: 0.5, step: 0.01 },
|
||||
},
|
||||
},
|
||||
|
||||
// === OUTPUT ===
|
||||
{
|
||||
type: 'output',
|
||||
name: 'Output',
|
||||
icon: '🔊',
|
||||
category: 'output',
|
||||
inputs: [
|
||||
{ name: 'left', type: 'audio', label: 'L' },
|
||||
{ name: 'right', type: 'audio', label: 'R' },
|
||||
],
|
||||
outputs: [],
|
||||
params: {
|
||||
volume: { type: 'knob', min: -60, max: 0, default: -12, unit: 'dB' },
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
export function getModuleDef(type: string): ModuleDef | undefined {
|
||||
return MODULE_REGISTRY.find((m) => m.type === type);
|
||||
}
|
||||
18
src/components/workbench/modules/synth/synthTypes.ts
Normal file
18
src/components/workbench/modules/synth/synthTypes.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
export interface SynthModule {
|
||||
id: string;
|
||||
type: string;
|
||||
x: number;
|
||||
y: number;
|
||||
params: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface SynthConnection {
|
||||
id: string;
|
||||
from: { moduleId: string; port: string };
|
||||
to: { moduleId: string; port: string };
|
||||
}
|
||||
|
||||
export interface SynthState {
|
||||
modules: SynthModule[];
|
||||
connections: SynthConnection[];
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Challenge, VerificationResult } from '@/types/challenge';
|
||||
import { Challenge, VerificationResult, CodeEditorContent, SignalPlaygroundContent, PixelEditorContent } from '@/types/challenge';
|
||||
import { CircuitBuilderContent, CircuitState } from '@/types/circuit';
|
||||
import { ElectronicsContent, ElectronicCircuitState } from '@/types/electronics';
|
||||
import { simulateCircuit } from '@/components/workbench/modules/circuit-builder/simulateCircuit';
|
||||
@@ -19,6 +19,12 @@ export function verifyAnswer(
|
||||
return verifyCircuit(content, userAnswer as string, challenge.xpReward);
|
||||
case 'electronics-lab':
|
||||
return verifyElectronics(content, userAnswer as string, challenge.xpReward);
|
||||
case 'code-editor':
|
||||
return verifyCode(content, userAnswer as string, challenge.xpReward);
|
||||
case 'signal-playground':
|
||||
return verifySignal(content, userAnswer as string, challenge.xpReward);
|
||||
case 'pixel-editor':
|
||||
return verifyPixels(content, userAnswer as string, challenge.xpReward);
|
||||
default:
|
||||
return { correct: false, message: 'Tipo de reto no soportado', xpEarned: 0 };
|
||||
}
|
||||
@@ -165,3 +171,107 @@ function verifyElectronics(
|
||||
const msg = messages.length > 0 ? messages[0] : `${passed} de ${content.probes.length} mediciones correctas.`;
|
||||
return { correct: false, message: msg, xpEarned: 0 };
|
||||
}
|
||||
|
||||
function verifyCode(
|
||||
content: CodeEditorContent,
|
||||
userAnswer: string,
|
||||
xpReward: number
|
||||
): VerificationResult {
|
||||
if (!userAnswer.trim()) {
|
||||
return { correct: false, message: 'Escribe tu código primero', xpEarned: 0 };
|
||||
}
|
||||
|
||||
let passed = 0;
|
||||
for (const tc of content.testCases) {
|
||||
if (content.language === 'javascript') {
|
||||
try {
|
||||
const fn = new Function('input', userAnswer);
|
||||
const result = String(fn(tc.input) ?? '').trim();
|
||||
if (result === tc.expectedOutput.trim()) passed++;
|
||||
} catch {
|
||||
return { correct: false, message: 'Error al ejecutar tu código', xpEarned: 0 };
|
||||
}
|
||||
} else {
|
||||
// For non-JS languages, check if code contains expected patterns
|
||||
if (userAnswer.trim().includes(tc.expectedOutput.trim())) passed++;
|
||||
}
|
||||
}
|
||||
|
||||
if (passed === content.testCases.length) {
|
||||
return { correct: true, message: '¡Todos los tests pasan! 🎉', xpEarned: xpReward };
|
||||
}
|
||||
|
||||
return {
|
||||
correct: false,
|
||||
message: `${passed} de ${content.testCases.length} tests correctos. Revisa tu código.`,
|
||||
xpEarned: 0,
|
||||
};
|
||||
}
|
||||
|
||||
function verifySignal(
|
||||
content: SignalPlaygroundContent,
|
||||
userAnswer: string,
|
||||
xpReward: number
|
||||
): VerificationResult {
|
||||
try {
|
||||
const state = JSON.parse(userAnswer);
|
||||
const checks: string[] = [];
|
||||
|
||||
if (content.targetFrequency) {
|
||||
const tolerance = content.targetFrequency * 0.05; // 5%
|
||||
if (Math.abs(state.frequency - content.targetFrequency) > tolerance) {
|
||||
checks.push(`Frecuencia: esperado ~${content.targetFrequency}Hz, tienes ${state.frequency}Hz`);
|
||||
}
|
||||
}
|
||||
if (content.targetWaveform && state.waveform !== content.targetWaveform) {
|
||||
checks.push(`Forma de onda: esperado ${content.targetWaveform}, tienes ${state.waveform}`);
|
||||
}
|
||||
|
||||
if (checks.length === 0) {
|
||||
return { correct: true, message: '¡Configuración correcta! 🎵', xpEarned: xpReward };
|
||||
}
|
||||
return { correct: false, message: checks[0], xpEarned: 0 };
|
||||
} catch {
|
||||
return { correct: false, message: 'Ajusta los parámetros del sintetizador', xpEarned: 0 };
|
||||
}
|
||||
}
|
||||
|
||||
function verifyPixels(
|
||||
content: PixelEditorContent,
|
||||
userAnswer: string,
|
||||
xpReward: number
|
||||
): VerificationResult {
|
||||
if (content.mode !== 'match' || !content.targetImage) {
|
||||
// Freeform mode — just check that something was drawn
|
||||
try {
|
||||
const grid: number[][] = JSON.parse(userAnswer);
|
||||
const hasContent = grid.some((row) => row.some((c) => c !== 0));
|
||||
if (hasContent) {
|
||||
return { correct: true, message: '¡Dibujo completado! 🎨', xpEarned: xpReward };
|
||||
}
|
||||
return { correct: false, message: 'Dibuja algo en el canvas', xpEarned: 0 };
|
||||
} catch {
|
||||
return { correct: false, message: 'Error en el canvas', xpEarned: 0 };
|
||||
}
|
||||
}
|
||||
|
||||
// Match mode
|
||||
try {
|
||||
const grid: number[][] = JSON.parse(userAnswer);
|
||||
let total = 0;
|
||||
let matches = 0;
|
||||
for (let y = 0; y < content.height; y++) {
|
||||
for (let x = 0; x < content.width; x++) {
|
||||
total++;
|
||||
if (grid[y]?.[x] === (content.targetImage[y]?.[x] ?? 0)) matches++;
|
||||
}
|
||||
}
|
||||
const pct = Math.round((matches / total) * 100);
|
||||
if (pct >= 95) {
|
||||
return { correct: true, message: `¡Imagen correcta! (${pct}%) 🎨`, xpEarned: xpReward };
|
||||
}
|
||||
return { correct: false, message: `Coincidencia: ${pct}%. Necesitas al menos 95%.`, xpEarned: 0 };
|
||||
} catch {
|
||||
return { correct: false, message: 'Error en el canvas', xpEarned: 0 };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,7 +28,42 @@ export interface MultipleChoiceContent {
|
||||
import { CircuitBuilderContent } from './circuit';
|
||||
import { ElectronicsContent } from './electronics';
|
||||
|
||||
export type ChallengeContent = MathInputContent | MultipleChoiceContent | CircuitBuilderContent | ElectronicsContent;
|
||||
export interface CodeEditorContent {
|
||||
type: 'code-editor';
|
||||
language: 'asm' | 'c' | 'verilog' | 'html' | 'javascript';
|
||||
starterCode: string;
|
||||
testCases: Array<{
|
||||
input: string;
|
||||
expectedOutput: string;
|
||||
label?: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface SignalPlaygroundContent {
|
||||
type: 'signal-playground';
|
||||
mode: 'waveform' | 'oscillator' | 'filter' | 'synth';
|
||||
targetFrequency?: number;
|
||||
targetWaveform?: 'sine' | 'square' | 'sawtooth' | 'triangle';
|
||||
instructions: string;
|
||||
}
|
||||
|
||||
export interface PixelEditorContent {
|
||||
type: 'pixel-editor';
|
||||
width: number;
|
||||
height: number;
|
||||
palette: string[];
|
||||
targetImage?: number[][]; // grid of palette indices
|
||||
mode: 'freeform' | 'match';
|
||||
}
|
||||
|
||||
export type ChallengeContent =
|
||||
| MathInputContent
|
||||
| MultipleChoiceContent
|
||||
| CircuitBuilderContent
|
||||
| ElectronicsContent
|
||||
| CodeEditorContent
|
||||
| SignalPlaygroundContent
|
||||
| PixelEditorContent;
|
||||
|
||||
export interface Challenge {
|
||||
id: string;
|
||||
|
||||
Reference in New Issue
Block a user