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:
Jose Luis Montañes
2026-03-26 14:09:14 +01:00
parent 8d8a811ede
commit 75ee19f8a3
20 changed files with 2956 additions and 4 deletions

8
.dockerignore Normal file
View File

@@ -0,0 +1,8 @@
node_modules
.next
.git
*.zip
todo.md
idea.txt
CLAUDE.md
AGENTS.md

1
.gitignore vendored
View File

@@ -39,3 +39,4 @@ yarn-error.log*
# typescript # typescript
*.tsbuildinfo *.tsbuildinfo
next-env.d.ts next-env.d.ts
project-math.zip

20
Dockerfile Normal file
View 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"]

View File

@@ -1,7 +1,7 @@
import type { NextConfig } from "next"; import type { NextConfig } from "next";
const nextConfig: NextConfig = { const nextConfig: NextConfig = {
/* config options here */ output: "standalone",
}; };
export default nextConfig; export default nextConfig;

107
package-lock.json generated
View File

@@ -10,6 +10,7 @@
"dependencies": { "dependencies": {
"@base-ui/react": "^1.3.0", "@base-ui/react": "^1.3.0",
"@dagrejs/dagre": "^3.0.0", "@dagrejs/dagre": "^3.0.0",
"@monaco-editor/react": "^4.7.0",
"@types/katex": "^0.16.8", "@types/katex": "^0.16.8",
"@xyflow/react": "^12.10.1", "@xyflow/react": "^12.10.1",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
@@ -21,6 +22,7 @@
"react-dom": "19.2.4", "react-dom": "19.2.4",
"shadcn": "^4.1.0", "shadcn": "^4.1.0",
"tailwind-merge": "^3.5.0", "tailwind-merge": "^3.5.0",
"tone": "^15.1.22",
"tw-animate-css": "^1.4.0", "tw-animate-css": "^1.4.0",
"zustand": "^5.0.12" "zustand": "^5.0.12"
}, },
@@ -1662,6 +1664,29 @@
"integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
"license": "MIT" "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": { "node_modules/@mswjs/interceptors": {
"version": "0.41.3", "version": "0.41.3",
"resolved": "https://registry.npmjs.org/@mswjs/interceptors/-/interceptors-0.41.3.tgz", "resolved": "https://registry.npmjs.org/@mswjs/interceptors/-/interceptors-0.41.3.tgz",
@@ -2445,6 +2470,14 @@
"integrity": "sha512-xMAgYwceFhRA2zY+XbEA7mxYbA093wdiW8Vu6gZPGWy9cmOyU9XesH1tNcEWsKFd5Vzrqx5T3D38PWx1FIIXkA==", "integrity": "sha512-xMAgYwceFhRA2zY+XbEA7mxYbA093wdiW8Vu6gZPGWy9cmOyU9XesH1tNcEWsKFd5Vzrqx5T3D38PWx1FIIXkA==",
"license": "MIT" "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": { "node_modules/@types/validate-npm-package-name": {
"version": "4.0.2", "version": "4.0.2",
"resolved": "https://registry.npmjs.org/@types/validate-npm-package-name/-/validate-npm-package-name-4.0.2.tgz", "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": ">= 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": { "node_modules/available-typed-arrays": {
"version": "1.0.7", "version": "1.0.7",
"resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz",
@@ -4303,6 +4349,16 @@
"node": ">=0.10.0" "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": { "node_modules/dotenv": {
"version": "17.3.1", "version": "17.3.1",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.3.1.tgz", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.3.1.tgz",
@@ -7098,6 +7154,19 @@
"@jridgewell/sourcemap-codec": "^1.5.5" "@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": { "node_modules/math-intrinsics": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
@@ -7224,6 +7293,17 @@
"url": "https://github.com/sponsors/ljharb" "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": { "node_modules/ms": {
"version": "2.1.3", "version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
@@ -8853,6 +8933,23 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/statuses": {
"version": "2.0.2", "version": "2.0.2",
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
@@ -9287,6 +9384,16 @@
"node": ">=0.6" "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": { "node_modules/tough-cookie": {
"version": "6.0.1", "version": "6.0.1",
"resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.1.tgz", "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.1.tgz",

View File

@@ -11,6 +11,7 @@
"dependencies": { "dependencies": {
"@base-ui/react": "^1.3.0", "@base-ui/react": "^1.3.0",
"@dagrejs/dagre": "^3.0.0", "@dagrejs/dagre": "^3.0.0",
"@monaco-editor/react": "^4.7.0",
"@types/katex": "^0.16.8", "@types/katex": "^0.16.8",
"@xyflow/react": "^12.10.1", "@xyflow/react": "^12.10.1",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
@@ -22,6 +23,7 @@
"react-dom": "19.2.4", "react-dom": "19.2.4",
"shadcn": "^4.1.0", "shadcn": "^4.1.0",
"tailwind-merge": "^3.5.0", "tailwind-merge": "^3.5.0",
"tone": "^15.1.22",
"tw-animate-css": "^1.4.0", "tw-animate-css": "^1.4.0",
"zustand": "^5.0.12" "zustand": "^5.0.12"
}, },

View File

@@ -2,12 +2,13 @@
import Link from 'next/link'; import Link from 'next/link';
import { usePathname } from 'next/navigation'; 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 { XPBar } from '@/components/common/XPBar';
import { StreakBadge } from '@/components/common/StreakBadge'; import { StreakBadge } from '@/components/common/StreakBadge';
const navItems = [ const navItems = [
{ href: '/skill-tree', label: 'Árbol', icon: TreePine }, { href: '/skill-tree', label: 'Árbol', icon: TreePine },
{ href: '/sandbox', label: 'Sandbox', icon: Box },
{ href: '/profile', label: 'Perfil', icon: User }, { href: '/profile', label: 'Perfil', icon: User },
]; ];

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

View File

@@ -13,6 +13,9 @@ import { MathInput } from './modules/MathInput';
import { MultipleChoice } from './modules/MultipleChoice'; import { MultipleChoice } from './modules/MultipleChoice';
import { CircuitBuilder } from './modules/circuit-builder/CircuitBuilder'; import { CircuitBuilder } from './modules/circuit-builder/CircuitBuilder';
import { ElectronicsLab } from './modules/electronics/ElectronicsLab'; 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 { Scratchpad } from './Scratchpad';
import { ExplanationRenderer } from './ExplanationRenderer'; import { ExplanationRenderer } from './ExplanationRenderer';
@@ -309,6 +312,27 @@ export function WorkbenchShell({ challenge }: WorkbenchShellProps) {
disabled={done || phase === 'wrong-shake'} 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 */} {/* Wrong attempt feedback inline */}
{phase === 'wrong-shake' && ( {phase === 'wrong-shake' && (
<p className="text-sm text-red-400 mt-3 text-center"> <p className="text-sm text-red-400 mt-3 text-center">

View File

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

View 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}
</>
);
}

View File

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

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

View 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',
};

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

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

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

View 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[];
}

View File

@@ -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 { CircuitBuilderContent, CircuitState } from '@/types/circuit';
import { ElectronicsContent, ElectronicCircuitState } from '@/types/electronics'; import { ElectronicsContent, ElectronicCircuitState } from '@/types/electronics';
import { simulateCircuit } from '@/components/workbench/modules/circuit-builder/simulateCircuit'; import { simulateCircuit } from '@/components/workbench/modules/circuit-builder/simulateCircuit';
@@ -19,6 +19,12 @@ export function verifyAnswer(
return verifyCircuit(content, userAnswer as string, challenge.xpReward); return verifyCircuit(content, userAnswer as string, challenge.xpReward);
case 'electronics-lab': case 'electronics-lab':
return verifyElectronics(content, userAnswer as string, challenge.xpReward); 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: default:
return { correct: false, message: 'Tipo de reto no soportado', xpEarned: 0 }; 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.`; const msg = messages.length > 0 ? messages[0] : `${passed} de ${content.probes.length} mediciones correctas.`;
return { correct: false, message: msg, xpEarned: 0 }; 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 };
}
}

View File

@@ -28,7 +28,42 @@ export interface MultipleChoiceContent {
import { CircuitBuilderContent } from './circuit'; import { CircuitBuilderContent } from './circuit';
import { ElectronicsContent } from './electronics'; 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 { export interface Challenge {
id: string; id: string;