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:
146
src/components/workbench/modules/synth/Knob.tsx
Normal file
146
src/components/workbench/modules/synth/Knob.tsx
Normal file
@@ -0,0 +1,146 @@
|
||||
'use client';
|
||||
|
||||
import { useRef, useCallback, useState } from 'react';
|
||||
|
||||
const SIZE = 32;
|
||||
const RADIUS = 12;
|
||||
const START_ANGLE = 225;
|
||||
const END_ANGLE = -45;
|
||||
const RANGE = 270;
|
||||
|
||||
function polarToCart(cx: number, cy: number, r: number, deg: number) {
|
||||
const rad = (deg - 90) * Math.PI / 180;
|
||||
return { x: cx + r * Math.cos(rad), y: cy + r * Math.sin(rad) };
|
||||
}
|
||||
|
||||
function describeArc(cx: number, cy: number, r: number, startDeg: number, endDeg: number) {
|
||||
const start = polarToCart(cx, cy, r, endDeg);
|
||||
const end = polarToCart(cx, cy, r, startDeg);
|
||||
const large = endDeg - startDeg <= 180 ? '0' : '1';
|
||||
return `M ${start.x} ${start.y} A ${r} ${r} 0 ${large} 0 ${end.x} ${end.y}`;
|
||||
}
|
||||
|
||||
interface KnobProps {
|
||||
value: number;
|
||||
min: number;
|
||||
max: number;
|
||||
onChange: (v: number) => void;
|
||||
color?: string;
|
||||
modulated?: boolean;
|
||||
liveValue?: number;
|
||||
}
|
||||
|
||||
export function Knob({ value, min, max, onChange, color = 'var(--accent, #6366f1)', modulated = false, liveValue }: KnobProps) {
|
||||
const ref = useRef<SVGSVGElement>(null);
|
||||
const dragRef = useRef<{ startY: number; startValue: number } | null>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [editText, setEditText] = useState('');
|
||||
|
||||
const displayNum = liveValue !== undefined ? liveValue : value;
|
||||
const clampedDisplay = Math.max(min, Math.min(max, displayNum));
|
||||
const norm = Math.max(0, Math.min(1, (clampedDisplay - min) / (max - min)));
|
||||
const angleDeg = START_ANGLE - norm * RANGE;
|
||||
|
||||
const cx = SIZE / 2, cy = SIZE / 2;
|
||||
const trackPath = describeArc(cx, cy, RADIUS, END_ANGLE, START_ANGLE);
|
||||
const fillAngle = START_ANGLE - norm * RANGE;
|
||||
const fillPath = norm > 0.001 ? describeArc(cx, cy, RADIUS, fillAngle, START_ANGLE) : '';
|
||||
const dotPos = polarToCart(cx, cy, RADIUS - 4, angleDeg);
|
||||
|
||||
const baseNorm = Math.max(0, Math.min(1, (value - min) / (max - min)));
|
||||
const baseAngle = START_ANGLE - baseNorm * RANGE;
|
||||
const baseDotPos = polarToCart(cx, cy, RADIUS - 4, baseAngle);
|
||||
|
||||
const handlePointerDown = useCallback((e: React.PointerEvent) => {
|
||||
if (editing) return;
|
||||
e.preventDefault(); e.stopPropagation();
|
||||
dragRef.current = { startY: e.clientY, startValue: value };
|
||||
const handleMove = (me: PointerEvent) => {
|
||||
if (!dragRef.current) return;
|
||||
const dy = dragRef.current.startY - me.clientY;
|
||||
const sensitivity = (max - min) / 200;
|
||||
let newVal = dragRef.current.startValue + dy * sensitivity;
|
||||
newVal = Math.max(min, Math.min(max, newVal));
|
||||
if (max - min > 10 && Number.isInteger(min) && Number.isInteger(max)) {
|
||||
newVal = Math.round(newVal);
|
||||
}
|
||||
onChange(newVal);
|
||||
};
|
||||
const handleUp = () => {
|
||||
window.removeEventListener('pointermove', handleMove);
|
||||
window.removeEventListener('pointerup', handleUp);
|
||||
dragRef.current = null;
|
||||
};
|
||||
window.addEventListener('pointermove', handleMove);
|
||||
window.addEventListener('pointerup', handleUp);
|
||||
}, [value, min, max, onChange, editing]);
|
||||
|
||||
const handleWheel = useCallback((e: React.WheelEvent) => {
|
||||
if (editing) return;
|
||||
e.preventDefault(); e.stopPropagation();
|
||||
const step = (max - min) / 100;
|
||||
const newVal = Math.max(min, Math.min(max, value - Math.sign(e.deltaY) * step));
|
||||
onChange(newVal);
|
||||
}, [value, min, max, onChange, editing]);
|
||||
|
||||
const handleDoubleClick = useCallback((e: React.MouseEvent) => {
|
||||
e.preventDefault(); e.stopPropagation();
|
||||
setEditText(String(value));
|
||||
setEditing(true);
|
||||
setTimeout(() => inputRef.current?.focus(), 0);
|
||||
}, [value]);
|
||||
|
||||
const commitEdit = useCallback(() => {
|
||||
const parsed = parseFloat(editText);
|
||||
if (!isNaN(parsed)) {
|
||||
onChange(Math.max(min, Math.min(max, parsed)));
|
||||
}
|
||||
setEditing(false);
|
||||
}, [editText, min, max, onChange]);
|
||||
|
||||
if (editing) {
|
||||
return (
|
||||
<div className="knob-container" onWheel={(e) => e.stopPropagation()} style={{ width: SIZE, height: SIZE, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={editText}
|
||||
onChange={(e) => setEditText(e.target.value)}
|
||||
onKeyDown={(e) => { e.stopPropagation(); if (e.key === 'Enter') commitEdit(); if (e.key === 'Escape') setEditing(false); }}
|
||||
onBlur={commitEdit}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
style={{
|
||||
width: 48, height: 22, background: 'oklch(0.145 0 0)', border: '1px solid var(--accent, #6366f1)',
|
||||
borderRadius: 3, color: 'var(--accent, #6366f1)', fontSize: 10, textAlign: 'center',
|
||||
fontFamily: "'JetBrains Mono', monospace", outline: 'none',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ width: SIZE, height: SIZE, position: 'relative', flexShrink: 0 }} onWheel={handleWheel} onDoubleClick={handleDoubleClick}>
|
||||
<svg viewBox={`0 0 ${SIZE} ${SIZE}`} style={{ width: SIZE, height: SIZE, cursor: 'pointer' }}
|
||||
onPointerDown={handlePointerDown} ref={ref}>
|
||||
{/* Modulation glow ring */}
|
||||
{modulated && (
|
||||
<circle cx={cx} cy={cy} r={RADIUS + 1} fill="none" stroke={color} strokeWidth="1" strokeDasharray="3 2" opacity="0.7">
|
||||
<animateTransform attributeName="transform" type="rotate" from={`0 ${cx} ${cy}`} to={`360 ${cx} ${cy}`} dur="4s" repeatCount="indefinite" />
|
||||
</circle>
|
||||
)}
|
||||
{/* Track */}
|
||||
<path d={trackPath} fill="none" stroke="#333" strokeWidth="3" strokeLinecap="round" />
|
||||
{/* Fill */}
|
||||
{fillPath && <path d={fillPath} fill="none" stroke={color} strokeWidth="3" strokeLinecap="round" />}
|
||||
{/* Ghost dot at base value when modulated */}
|
||||
{liveValue !== undefined && (
|
||||
<circle cx={baseDotPos.x} cy={baseDotPos.y} r={1.5} fill="#fff" opacity="0.3" />
|
||||
)}
|
||||
{/* Current value dot */}
|
||||
<circle cx={dotPos.x} cy={dotPos.y} r={2} fill="#fff" />
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user