diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..affd09f --- /dev/null +++ b/.dockerignore @@ -0,0 +1,8 @@ +node_modules +.next +.git +*.zip +todo.md +idea.txt +CLAUDE.md +AGENTS.md diff --git a/.gitignore b/.gitignore index 5ef6a52..a59577b 100644 --- a/.gitignore +++ b/.gitignore @@ -39,3 +39,4 @@ yarn-error.log* # typescript *.tsbuildinfo next-env.d.ts +project-math.zip diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..8b391ed --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/next.config.ts b/next.config.ts index e9ffa30..68a6c64 100644 --- a/next.config.ts +++ b/next.config.ts @@ -1,7 +1,7 @@ import type { NextConfig } from "next"; const nextConfig: NextConfig = { - /* config options here */ + output: "standalone", }; export default nextConfig; diff --git a/package-lock.json b/package-lock.json index 2aa879b..6958033 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,7 @@ "dependencies": { "@base-ui/react": "^1.3.0", "@dagrejs/dagre": "^3.0.0", + "@monaco-editor/react": "^4.7.0", "@types/katex": "^0.16.8", "@xyflow/react": "^12.10.1", "class-variance-authority": "^0.7.1", @@ -21,6 +22,7 @@ "react-dom": "19.2.4", "shadcn": "^4.1.0", "tailwind-merge": "^3.5.0", + "tone": "^15.1.22", "tw-animate-css": "^1.4.0", "zustand": "^5.0.12" }, @@ -1662,6 +1664,29 @@ "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", "license": "MIT" }, + "node_modules/@monaco-editor/loader": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/@monaco-editor/loader/-/loader-1.7.0.tgz", + "integrity": "sha512-gIwR1HrJrrx+vfyOhYmCZ0/JcWqG5kbfG7+d3f/C1LXk2EvzAbHSg3MQ5lO2sMlo9izoAZ04shohfKLVT6crVA==", + "license": "MIT", + "dependencies": { + "state-local": "^1.0.6" + } + }, + "node_modules/@monaco-editor/react": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@monaco-editor/react/-/react-4.7.0.tgz", + "integrity": "sha512-cyzXQCtO47ydzxpQtCGSQGOC8Gk3ZUeBXFAxD+CWXYFo5OqZyZUonFl0DwUlTyAfRHntBfw2p3w4s9R6oe1eCA==", + "license": "MIT", + "dependencies": { + "@monaco-editor/loader": "^1.5.0" + }, + "peerDependencies": { + "monaco-editor": ">= 0.25.0 < 1", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/@mswjs/interceptors": { "version": "0.41.3", "resolved": "https://registry.npmjs.org/@mswjs/interceptors/-/interceptors-0.41.3.tgz", @@ -2445,6 +2470,14 @@ "integrity": "sha512-xMAgYwceFhRA2zY+XbEA7mxYbA093wdiW8Vu6gZPGWy9cmOyU9XesH1tNcEWsKFd5Vzrqx5T3D38PWx1FIIXkA==", "license": "MIT" }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "license": "MIT", + "optional": true, + "peer": true + }, "node_modules/@types/validate-npm-package-name": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/@types/validate-npm-package-name/-/validate-npm-package-name-4.0.2.tgz", @@ -3408,6 +3441,19 @@ "node": ">= 0.4" } }, + "node_modules/automation-events": { + "version": "7.1.16", + "resolved": "https://registry.npmjs.org/automation-events/-/automation-events-7.1.16.tgz", + "integrity": "sha512-vAAHG8WO+Cx2PfwmWIAxSD51ZYg+zRam52pzOGVAJOqsqQO6oaPM2k4/cdEF7QQ786FYB8Wzbw//qTWCdyGvzA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.29.2", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=18.2.0" + } + }, "node_modules/available-typed-arrays": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", @@ -4303,6 +4349,16 @@ "node": ">=0.10.0" } }, + "node_modules/dompurify": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.2.7.tgz", + "integrity": "sha512-WhL/YuveyGXJaerVlMYGWhvQswa7myDG17P7Vu65EWC05o8vfeNbvNf4d/BOvH99+ZW+LlQsc1GDKMa1vNK6dw==", + "license": "(MPL-2.0 OR Apache-2.0)", + "peer": true, + "optionalDependencies": { + "@types/trusted-types": "^2.0.7" + } + }, "node_modules/dotenv": { "version": "17.3.1", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.3.1.tgz", @@ -7098,6 +7154,19 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/marked": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/marked/-/marked-14.0.0.tgz", + "integrity": "sha512-uIj4+faQ+MgHgwUW1l2PsPglZLOLOT1uErt06dAPtx2kjteLAkbsd/0FiYg/MGS+i7ZKLb7w2WClxHkzOOuryQ==", + "license": "MIT", + "peer": true, + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 18" + } + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -7224,6 +7293,17 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/monaco-editor": { + "version": "0.55.1", + "resolved": "https://registry.npmjs.org/monaco-editor/-/monaco-editor-0.55.1.tgz", + "integrity": "sha512-jz4x+TJNFHwHtwuV9vA9rMujcZRb0CEilTEwG2rRSpe/A7Jdkuj8xPKttCgOh+v/lkHy7HsZ64oj+q3xoAFl9A==", + "license": "MIT", + "peer": true, + "dependencies": { + "dompurify": "3.2.7", + "marked": "14.0.0" + } + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -8853,6 +8933,23 @@ "dev": true, "license": "MIT" }, + "node_modules/standardized-audio-context": { + "version": "25.3.77", + "resolved": "https://registry.npmjs.org/standardized-audio-context/-/standardized-audio-context-25.3.77.tgz", + "integrity": "sha512-Ki9zNz6pKcC5Pi+QPjPyVsD9GwJIJWgryji0XL9cAJXMGyn+dPOf6Qik1AHei0+UNVcc4BOCa0hWLBzlwqsW/A==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.25.6", + "automation-events": "^7.0.9", + "tslib": "^2.7.0" + } + }, + "node_modules/state-local": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/state-local/-/state-local-1.0.7.tgz", + "integrity": "sha512-HTEHMNieakEnoe33shBYcZ7NX83ACUjCu8c40iOGEZsngj9zRnkqS9j1pqQPXwobB0ZcVTk27REb7COQ0UR59w==", + "license": "MIT" + }, "node_modules/statuses": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", @@ -9287,6 +9384,16 @@ "node": ">=0.6" } }, + "node_modules/tone": { + "version": "15.1.22", + "resolved": "https://registry.npmjs.org/tone/-/tone-15.1.22.tgz", + "integrity": "sha512-TCScAGD4sLsama5DjvTUXlLDXSqPealhL64nsdV1hhr6frPWve0DeSo63AKnSJwgfg55fhvxj0iPPRwPN5o0ag==", + "license": "MIT", + "dependencies": { + "standardized-audio-context": "^25.3.70", + "tslib": "^2.3.1" + } + }, "node_modules/tough-cookie": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.1.tgz", diff --git a/package.json b/package.json index 6c9baab..4ef5e34 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,7 @@ "dependencies": { "@base-ui/react": "^1.3.0", "@dagrejs/dagre": "^3.0.0", + "@monaco-editor/react": "^4.7.0", "@types/katex": "^0.16.8", "@xyflow/react": "^12.10.1", "class-variance-authority": "^0.7.1", @@ -22,6 +23,7 @@ "react-dom": "19.2.4", "shadcn": "^4.1.0", "tailwind-merge": "^3.5.0", + "tone": "^15.1.22", "tw-animate-css": "^1.4.0", "zustand": "^5.0.12" }, diff --git a/src/app/(main)/layout.tsx b/src/app/(main)/layout.tsx index 159bbd6..aaa1380 100644 --- a/src/app/(main)/layout.tsx +++ b/src/app/(main)/layout.tsx @@ -2,12 +2,13 @@ import Link from 'next/link'; import { usePathname } from 'next/navigation'; -import { TreePine, Wrench, User, Trophy } from 'lucide-react'; +import { TreePine, Box, User } from 'lucide-react'; import { XPBar } from '@/components/common/XPBar'; import { StreakBadge } from '@/components/common/StreakBadge'; const navItems = [ { href: '/skill-tree', label: 'Árbol', icon: TreePine }, + { href: '/sandbox', label: 'Sandbox', icon: Box }, { href: '/profile', label: 'Perfil', icon: User }, ]; diff --git a/src/app/(main)/sandbox/page.tsx b/src/app/(main)/sandbox/page.tsx new file mode 100644 index 0000000..2967ee4 --- /dev/null +++ b/src/app/(main)/sandbox/page.tsx @@ -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 ( +
+
+ + {info.icon} +

{info.title}

+ Sandbox +
+
+ {mode === 'synth' && } + {mode === 'circuit' && ( + {}} + /> + )} + {mode === 'electronics' && ( + {}} + /> + )} + {mode === 'code-editor' && ( + {}} + /> + )} + {mode === 'pixel-editor' && ( + {}} + /> + )} +
+
+ ); +} + +export default function SandboxPage() { + const [mode, setMode] = useState(null); + + if (mode) { + return setMode(null)} />; + } + + return ( +
+
+

Sandbox

+

Modo libre — experimenta con todas las herramientas

+
+
+
+ {MODES.map((m) => ( + setMode(m.id)} + > +
+ {m.icon} +
+
+

{m.title}

+ {m.tag} +
+

{m.description}

+
+
+
+ ))} +
+
+
+ ); +} diff --git a/src/components/workbench/WorkbenchShell.tsx b/src/components/workbench/WorkbenchShell.tsx index d144169..5620924 100644 --- a/src/components/workbench/WorkbenchShell.tsx +++ b/src/components/workbench/WorkbenchShell.tsx @@ -13,6 +13,9 @@ import { MathInput } from './modules/MathInput'; import { MultipleChoice } from './modules/MultipleChoice'; import { CircuitBuilder } from './modules/circuit-builder/CircuitBuilder'; import { ElectronicsLab } from './modules/electronics/ElectronicsLab'; +import { CodeEditorWorkbench } from './modules/code-editor/CodeEditorWorkbench'; +import { SignalPlayground } from './modules/signal-playground/SignalPlayground'; +import { PixelEditor } from './modules/pixel-editor/PixelEditor'; import { Scratchpad } from './Scratchpad'; import { ExplanationRenderer } from './ExplanationRenderer'; @@ -309,6 +312,27 @@ export function WorkbenchShell({ challenge }: WorkbenchShellProps) { disabled={done || phase === 'wrong-shake'} /> )} + {challenge.content.type === 'code-editor' && ( + setAnswer(code)} + disabled={done || phase === 'wrong-shake'} + /> + )} + {challenge.content.type === 'signal-playground' && ( + setAnswer(state)} + disabled={done || phase === 'wrong-shake'} + /> + )} + {challenge.content.type === 'pixel-editor' && ( + setAnswer(grid)} + disabled={done || phase === 'wrong-shake'} + /> + )} {/* Wrong attempt feedback inline */} {phase === 'wrong-shake' && (

diff --git a/src/components/workbench/modules/code-editor/CodeEditorWorkbench.tsx b/src/components/workbench/modules/code-editor/CodeEditorWorkbench.tsx new file mode 100644 index 0000000..8c3ae65 --- /dev/null +++ b/src/components/workbench/modules/code-editor/CodeEditorWorkbench.tsx @@ -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 = { + 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>([]); + const [fullscreen, setFullscreen] = useState(false); + const editorRef = useRef(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) => ( +

+ { editorRef.current = editor; }} + theme="vs-dark" + options={{ + minimap: { enabled: false }, + fontSize: 14, + lineNumbers: 'on', + scrollBeyondLastLine: false, + readOnly: disabled, + automaticLayout: true, + tabSize: 2, + wordWrap: 'on', + }} + /> +
+ ); + + const testPanel = ( +
+
+ + {allPassed && Todos los tests pasan} +
+ +
+ + {content.testCases.map((tc, i) => { + const result = testResults[i]; + return ( +
+
+ {result && (result.passed + ? + : + )} + {tc.label || `Test ${i + 1}`} +
+ {tc.input &&
Input: {tc.input}
} +
Esperado: {tc.expectedOutput}
+ {result &&
Tu resultado: {result.actual || (vacío)}
} + {result?.error &&
{result.error}
} +
+ ); + })} +
+ ); + + // Fullscreen portal + const fullscreenOverlay = fullscreen && createPortal( +
{ if (e.key === 'Escape') { e.stopPropagation(); setFullscreen(false); } }}> +
+
{editorComponent(true)}
+
+ {testPanel} +
+
+
, + document.body + ); + + return ( +
+
+ {editorComponent(false)} +
+ {testPanel} + {fullscreenOverlay} +
+ ); +} diff --git a/src/components/workbench/modules/pixel-editor/PixelEditor.tsx b/src/components/workbench/modules/pixel-editor/PixelEditor.tsx new file mode 100644 index 0000000..d01f980 --- /dev/null +++ b/src/components/workbench/modules/pixel-editor/PixelEditor.tsx @@ -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(() => + 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(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) => { + 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) => { + const cell = getCell(e); + if (cell) { paint(cell.x, cell.y); setIsDrawing(true); } + }, [getCell, paint]); + + const handleMouseMove = useCallback((e: React.MouseEvent) => { + 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) => ( +
+ {/* Canvas */} +
+ + {matchPct !== null && ( +
+ + Coincidencia: {matchPct}% + +
+ )} +
+ + {/* Tools */} +
+ {/* Palette */} +
+ {fs &&

Paleta

} + {palette.map((color, i) => ( +
+ +
+ + + +
+
+
+ ); + + const fullscreenOverlay = fullscreen && createPortal( +
{ if (e.key === 'Escape') { e.stopPropagation(); setFullscreen(false); } }}> +
+ {editorUI(true)} +
+
, + document.body + ); + + return ( + <> + {editorUI(false)} + {fullscreenOverlay} + + ); +} diff --git a/src/components/workbench/modules/signal-playground/SignalPlayground.tsx b/src/components/workbench/modules/signal-playground/SignalPlayground.tsx new file mode 100644 index 0000000..d812cdc --- /dev/null +++ b/src/components/workbench/modules/signal-playground/SignalPlayground.tsx @@ -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 = { + 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(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('lowpass'); + const canvasRef = useRef(null); + const audioCtxRef = useRef(null); + const oscRef = useRef(null); + const gainRef = useRef(null); + const filterRef = useRef(null); + const analyserRef = useRef(null); + const animRef = useRef(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) => ( +
+
+ + + {playing ? '♪ Reproduciendo' : '○ Silencio'} + + {!fs && ( + + )} + {fs && ( + + )} +
+ + {/* Waveform selector */} +
+ +
+ {WAVE_TYPES.map((w) => ( + + ))} +
+
+ + {/* Frequency */} +
+ + setFrequency(Number(e.target.value))} + className="w-full accent-primary" /> +
+ + {/* Volume */} +
+ + setVolume(Number(e.target.value))} + className="w-full accent-primary" /> +
+ + {/* Filter controls */} + {hasFilter && ( + <> +
+ +
+ {(['lowpass', 'highpass', 'bandpass'] as BiquadFilterType[]).map((ft) => ( + + ))} +
+
+
+ + setFilterFreq(Number(e.target.value))} + className="w-full accent-primary" /> +
+ + )} + +

{content.instructions}

+
+ ); + + const canvas = (fs: boolean) => ( + + ); + + const fullscreenOverlay = fullscreen && createPortal( +
{ if (e.key === 'Escape') { e.stopPropagation(); setFullscreen(false); } }}> +
+ {controls(true)} +
+
+ {canvas(true)} +
+
, + document.body + ); + + return ( +
+ {canvas(false)} + {controls(false)} + {fullscreenOverlay} +
+ ); +} diff --git a/src/components/workbench/modules/synth/Knob.tsx b/src/components/workbench/modules/synth/Knob.tsx new file mode 100644 index 0000000..728e206 --- /dev/null +++ b/src/components/workbench/modules/synth/Knob.tsx @@ -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(null); + const dragRef = useRef<{ startY: number; startValue: number } | null>(null); + const inputRef = useRef(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 ( +
e.stopPropagation()} style={{ width: SIZE, height: SIZE, display: 'flex', alignItems: 'center', justifyContent: 'center' }}> + 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', + }} + /> +
+ ); + } + + return ( +
+ + {/* Modulation glow ring */} + {modulated && ( + + + + )} + {/* Track */} + + {/* Fill */} + {fillPath && } + {/* Ghost dot at base value when modulated */} + {liveValue !== undefined && ( + + )} + {/* Current value dot */} + + +
+ ); +} diff --git a/src/components/workbench/modules/synth/ModularSynth.tsx b/src/components/workbench/modules/synth/ModularSynth.tsx new file mode 100644 index 0000000..9d4a6fc --- /dev/null +++ b/src/components/workbench/modules/synth/ModularSynth.tsx @@ -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([]); + const [connections, setConnections] = useState([]); + const [audioStarted, setAudioStarted] = useState(false); + const [playing, setPlaying] = useState(false); + const [fullscreen, setFullscreen] = useState(false); + const [wiring, setWiring] = useState(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>(new Map()); + const portRefs = useRef>(new Map()); + const canvasRef = useRef(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 = {}; + 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( + { + 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( + + ); + } + + return lines; + }; + + const categories = [...new Set(MODULE_REGISTRY.map((m) => m.category))]; + + const synthContent = (fs: boolean) => ( +
+ {/* Toolbar */} +
+ + SYNTH + +
+ + {!playing ? ( + + ) : ( + + )} + +
+ + + +
+ + +
+ + {/* Main area */} +
e.preventDefault()}> + + {/* Canvas */} +
+ {/* Grid background */} + + + + + + + + + + {/* Transformed layer */} +
+ {/* Wire SVG */} + + {renderWires()} + + + {/* Modules */} + {modules.map((mod) => ( + + ))} +
+
+ + {/* Palette (floating) */} +
+ {categories.map((cat) => ( +
+
+ {cat} +
+ {MODULE_REGISTRY.filter((m) => m.category === cat).map((m) => ( + + ))} +
+ ))} +
+ + {/* Zoom controls */} +
+ + + +
+ + {/* Empty state */} + {modules.length === 0 && ( +
+ Selecciona un módulo del panel izquierdo para empezar +
+ )} +
+ + {/* Status bar */} +
+ Módulos: {modules.length} + Conexiones: {connections.length} + Zoom: {Math.round(zoom * 100)}% +
+ Scroll: zoom · Click medio: pan · Click en cable: desconectar +
+
+ ); + + const fullscreenOverlay = fullscreen && createPortal( +
{ if (e.key === 'Escape') { e.stopPropagation(); setFullscreen(false); } }} + tabIndex={0} + > + {synthContent(true)} +
, + 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', +}; diff --git a/src/components/workbench/modules/synth/SynthModuleNode.tsx b/src/components/workbench/modules/synth/SynthModuleNode.tsx new file mode 100644 index 0000000..70b022b --- /dev/null +++ b/src/components/workbench/modules/synth/SynthModuleNode.tsx @@ -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> = { + 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 = { + 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(); + 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>({}); + const rafRef = useRef(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 = {}; + 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 ( +
+
{ (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); + }} + /> + {port.label} +
+ ); + }; + + // 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()); + 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 ( +
+
+ {/* Header */} +
+ {def.icon} + {def.name} + {(mod.type === 'keyboard' || mod.type === 'drumpad') && ( + + )} + +
+ +
+ {/* 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 ( +
+ {paramDef.label || name} + onParamChange(mod.id, name, v)} + color={pColor} modulated={modulatedParams.has(name)} liveValue={live} /> + + {formatVal(displayVal)}{paramDef.unit ? ` ${paramDef.unit}` : ''} + +
+ ); + } + if (paramDef.type === 'select') { + return ( +
+ {paramDef.label || name} + +
+ ); + } + return null; + })} + + {/* Keyboard widget */} + {mod.type === 'keyboard' && ( +
e.stopPropagation()}> + + {[0, 2, 4, 5, 7, 9, 11].map((note, i) => ( + 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 ( + triggerNote(note, true)} onPointerUp={() => triggerNote(note, false)} /> + ); + })} + +
+ Z-M / Q-I keys · Oct {(mod.params.octave as number) ?? 4} +
+
+ )} + + {/* 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 ( +
e.stopPropagation()}> +
+ {PAD_NOTES.map((pad, i) => ( +
{ + 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} +
+ ))} +
+
Tap pads to trigger
+
+ ); + })()} + + {/* 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 ( +
e.stopPropagation()}> + + {steps.map((s, i) => { + const x = i * CELL_W; + const barHeight = ((s.midi - 36) / 60) * (CELL_H - 4); + return ( + + + {s.gate && ( + + )} + {!s.gate && ( + + )} + + {noteLabel(s.midi)} + + {/* Click zones: top=pitch up, mid=toggle, bottom=pitch down */} + changeNote(i, 1)} /> + toggleGate(i)} /> + changeNote(i, -1)} /> + + ); + })} + +
+ ↑top/↓bot: pitch · mid: toggle +
+
+ ); + })()} + + {/* Output ports */} + {def.outputs.map(p => renderPort(p, 'output'))} +
+
+ + {/* Fullscreen keyboard portal */} + {fullscreen && mod.type === 'keyboard' && createPortal( +
{ if (e.key === 'Escape') setFullscreen(false); }}> +
+ + Oct {(mod.params.octave as number) ?? 4} +
+
+ {[0, 2, 4, 5, 7, 9, 11].map((note, i) => ( +
triggerNote(note, true)} onPointerUp={() => triggerNote(note, false)} onPointerLeave={() => triggerNote(note, false)}> + {['C', 'D', 'E', 'F', 'G', 'A', 'B'][i]} +
+ ))} + {[{ note: 1, after: 0 }, { note: 3, after: 1 }, { note: 6, after: 3 }, { note: 8, after: 4 }, { note: 10, after: 5 }].map(k => ( +
triggerNote(k.note, true)} onPointerUp={() => triggerNote(k.note, false)} onPointerLeave={() => triggerNote(k.note, false)} /> + ))} +
+
, + document.body + )} +
+ ); +} diff --git a/src/components/workbench/modules/synth/audioEngine.ts b/src/components/workbench/modules/synth/audioEngine.ts new file mode 100644 index 0000000..c197c2e --- /dev/null +++ b/src/components/workbench/modules/synth/audioEngine.ts @@ -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; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + outputs: Record; + dispose: () => void; +} + +export function createAudioNode( + type: string, + params: Record +): 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 | 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(); +} diff --git a/src/components/workbench/modules/synth/moduleRegistry.ts b/src/components/workbench/modules/synth/moduleRegistry.ts new file mode 100644 index 0000000..c07772e --- /dev/null +++ b/src/components/workbench/modules/synth/moduleRegistry.ts @@ -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; +} + +export const PORT_COLORS: Record = { + 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); +} diff --git a/src/components/workbench/modules/synth/synthTypes.ts b/src/components/workbench/modules/synth/synthTypes.ts new file mode 100644 index 0000000..fa1a497 --- /dev/null +++ b/src/components/workbench/modules/synth/synthTypes.ts @@ -0,0 +1,18 @@ +export interface SynthModule { + id: string; + type: string; + x: number; + y: number; + params: Record; +} + +export interface SynthConnection { + id: string; + from: { moduleId: string; port: string }; + to: { moduleId: string; port: string }; +} + +export interface SynthState { + modules: SynthModule[]; + connections: SynthConnection[]; +} diff --git a/src/lib/challenge-engine/verifier.ts b/src/lib/challenge-engine/verifier.ts index b15f1d2..d74132f 100644 --- a/src/lib/challenge-engine/verifier.ts +++ b/src/lib/challenge-engine/verifier.ts @@ -1,4 +1,4 @@ -import { Challenge, VerificationResult } from '@/types/challenge'; +import { Challenge, VerificationResult, CodeEditorContent, SignalPlaygroundContent, PixelEditorContent } from '@/types/challenge'; import { CircuitBuilderContent, CircuitState } from '@/types/circuit'; import { ElectronicsContent, ElectronicCircuitState } from '@/types/electronics'; import { simulateCircuit } from '@/components/workbench/modules/circuit-builder/simulateCircuit'; @@ -19,6 +19,12 @@ export function verifyAnswer( return verifyCircuit(content, userAnswer as string, challenge.xpReward); case 'electronics-lab': return verifyElectronics(content, userAnswer as string, challenge.xpReward); + case 'code-editor': + return verifyCode(content, userAnswer as string, challenge.xpReward); + case 'signal-playground': + return verifySignal(content, userAnswer as string, challenge.xpReward); + case 'pixel-editor': + return verifyPixels(content, userAnswer as string, challenge.xpReward); default: return { correct: false, message: 'Tipo de reto no soportado', xpEarned: 0 }; } @@ -165,3 +171,107 @@ function verifyElectronics( const msg = messages.length > 0 ? messages[0] : `${passed} de ${content.probes.length} mediciones correctas.`; return { correct: false, message: msg, xpEarned: 0 }; } + +function verifyCode( + content: CodeEditorContent, + userAnswer: string, + xpReward: number +): VerificationResult { + if (!userAnswer.trim()) { + return { correct: false, message: 'Escribe tu código primero', xpEarned: 0 }; + } + + let passed = 0; + for (const tc of content.testCases) { + if (content.language === 'javascript') { + try { + const fn = new Function('input', userAnswer); + const result = String(fn(tc.input) ?? '').trim(); + if (result === tc.expectedOutput.trim()) passed++; + } catch { + return { correct: false, message: 'Error al ejecutar tu código', xpEarned: 0 }; + } + } else { + // For non-JS languages, check if code contains expected patterns + if (userAnswer.trim().includes(tc.expectedOutput.trim())) passed++; + } + } + + if (passed === content.testCases.length) { + return { correct: true, message: '¡Todos los tests pasan! 🎉', xpEarned: xpReward }; + } + + return { + correct: false, + message: `${passed} de ${content.testCases.length} tests correctos. Revisa tu código.`, + xpEarned: 0, + }; +} + +function verifySignal( + content: SignalPlaygroundContent, + userAnswer: string, + xpReward: number +): VerificationResult { + try { + const state = JSON.parse(userAnswer); + const checks: string[] = []; + + if (content.targetFrequency) { + const tolerance = content.targetFrequency * 0.05; // 5% + if (Math.abs(state.frequency - content.targetFrequency) > tolerance) { + checks.push(`Frecuencia: esperado ~${content.targetFrequency}Hz, tienes ${state.frequency}Hz`); + } + } + if (content.targetWaveform && state.waveform !== content.targetWaveform) { + checks.push(`Forma de onda: esperado ${content.targetWaveform}, tienes ${state.waveform}`); + } + + if (checks.length === 0) { + return { correct: true, message: '¡Configuración correcta! 🎵', xpEarned: xpReward }; + } + return { correct: false, message: checks[0], xpEarned: 0 }; + } catch { + return { correct: false, message: 'Ajusta los parámetros del sintetizador', xpEarned: 0 }; + } +} + +function verifyPixels( + content: PixelEditorContent, + userAnswer: string, + xpReward: number +): VerificationResult { + if (content.mode !== 'match' || !content.targetImage) { + // Freeform mode — just check that something was drawn + try { + const grid: number[][] = JSON.parse(userAnswer); + const hasContent = grid.some((row) => row.some((c) => c !== 0)); + if (hasContent) { + return { correct: true, message: '¡Dibujo completado! 🎨', xpEarned: xpReward }; + } + return { correct: false, message: 'Dibuja algo en el canvas', xpEarned: 0 }; + } catch { + return { correct: false, message: 'Error en el canvas', xpEarned: 0 }; + } + } + + // Match mode + try { + const grid: number[][] = JSON.parse(userAnswer); + let total = 0; + let matches = 0; + for (let y = 0; y < content.height; y++) { + for (let x = 0; x < content.width; x++) { + total++; + if (grid[y]?.[x] === (content.targetImage[y]?.[x] ?? 0)) matches++; + } + } + const pct = Math.round((matches / total) * 100); + if (pct >= 95) { + return { correct: true, message: `¡Imagen correcta! (${pct}%) 🎨`, xpEarned: xpReward }; + } + return { correct: false, message: `Coincidencia: ${pct}%. Necesitas al menos 95%.`, xpEarned: 0 }; + } catch { + return { correct: false, message: 'Error en el canvas', xpEarned: 0 }; + } +} diff --git a/src/types/challenge.ts b/src/types/challenge.ts index 6a3013a..b4d507b 100644 --- a/src/types/challenge.ts +++ b/src/types/challenge.ts @@ -28,7 +28,42 @@ export interface MultipleChoiceContent { import { CircuitBuilderContent } from './circuit'; import { ElectronicsContent } from './electronics'; -export type ChallengeContent = MathInputContent | MultipleChoiceContent | CircuitBuilderContent | ElectronicsContent; +export interface CodeEditorContent { + type: 'code-editor'; + language: 'asm' | 'c' | 'verilog' | 'html' | 'javascript'; + starterCode: string; + testCases: Array<{ + input: string; + expectedOutput: string; + label?: string; + }>; +} + +export interface SignalPlaygroundContent { + type: 'signal-playground'; + mode: 'waveform' | 'oscillator' | 'filter' | 'synth'; + targetFrequency?: number; + targetWaveform?: 'sine' | 'square' | 'sawtooth' | 'triangle'; + instructions: string; +} + +export interface PixelEditorContent { + type: 'pixel-editor'; + width: number; + height: number; + palette: string[]; + targetImage?: number[][]; // grid of palette indices + mode: 'freeform' | 'match'; +} + +export type ChallengeContent = + | MathInputContent + | MultipleChoiceContent + | CircuitBuilderContent + | ElectronicsContent + | CodeEditorContent + | SignalPlaygroundContent + | PixelEditorContent; export interface Challenge { id: string;