refactor: restructure to monorepo with npm workspaces (Phase 0)

Move frontend to packages/client/, server to packages/server/.
Root package.json uses npm workspaces to orchestrate both.

Structure:
  reaktor/
    packages/client/  (React + Vite + Tone.js frontend)
    packages/server/  (static file server, future API)
    dist/             (built output, shared)
    docker-compose.yml (app + PostgreSQL for future backend)

- npm run dev → runs Vite dev server from client workspace
- npm run build → builds client, outputs to root dist/
- npm run start → runs server.js serving dist/
- Dockerfile updated for multi-stage monorepo build
- docker-compose.yml added with PostgreSQL service (ready for Phase 1)
- All imports and paths preserved, zero functionality change

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jose Luis
2026-03-21 19:52:57 +01:00
parent 4baa86eed0
commit b058997889
59 changed files with 96 additions and 33 deletions

View File

@@ -0,0 +1,52 @@
import { useState, useRef, useCallback } from 'react';
export default function BottomSheet({ tabs, activeTab, onTabChange, children, className = '' }) {
const [expanded, setExpanded] = useState(false);
const startY = useRef(0);
const handleTouchStart = useCallback((e) => {
startY.current = e.touches[0].clientY;
}, []);
const handleTouchEnd = useCallback((e) => {
const deltaY = e.changedTouches[0].clientY - startY.current;
if (deltaY < -30) setExpanded(true);
if (deltaY > 30) setExpanded(false);
}, []);
return (
<div
className={`bottom-sheet ${expanded ? 'expanded' : 'collapsed'} ${className}`}
onTouchStart={handleTouchStart}
onTouchEnd={handleTouchEnd}
>
<div className="bottom-sheet-handle" onClick={() => setExpanded(v => !v)}>
<div className="bottom-sheet-handle-bar" />
{!expanded && !tabs && (
<span className="bottom-sheet-peek-label">Modulos </span>
)}
</div>
{tabs && tabs.length > 0 && (
<div className="bottom-sheet-tabs" onClick={() => !expanded && setExpanded(true)}>
{tabs.map(tab => (
<button
key={tab.id}
className={`bottom-sheet-tab ${activeTab === tab.id ? 'active' : ''}`}
onClick={() => { onTabChange?.(tab.id); setExpanded(true); }}
>
{tab.label}
{activeTab === tab.id && <div className="bottom-sheet-tab-line" />}
</button>
))}
</div>
)}
{expanded && (
<div className="bottom-sheet-content">
{children}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,109 @@
import React, { useCallback, useState } from 'react';
import { createPortal } from 'react-dom';
import { triggerKeyboard } from '../engine/audioEngine.js';
// 4x4 pad layout — each pad maps to a MIDI note
const PAD_NOTES = [
{ note: 36, label: 'C2', color: '#ff4466' },
{ note: 38, label: 'D2', color: '#ff6644' },
{ note: 40, label: 'E2', color: '#ffcc00' },
{ note: 42, label: 'F#2', color: '#44ff88' },
{ note: 43, label: 'G2', color: '#00e5ff' },
{ note: 45, label: 'A2', color: '#aa55ff' },
{ note: 47, label: 'B2', color: '#ff4466' },
{ note: 48, label: 'C3', color: '#ff6644' },
{ note: 50, label: 'D3', color: '#ffcc00' },
{ note: 52, label: 'E3', color: '#44ff88' },
{ note: 53, label: 'F3', color: '#00e5ff' },
{ note: 55, label: 'G3', color: '#aa55ff' },
{ note: 57, label: 'A3', color: '#ff4466' },
{ note: 59, label: 'B3', color: '#ff6644' },
{ note: 60, label: 'C4', color: '#ffcc00' },
{ note: 62, label: 'D4', color: '#44ff88' },
];
function midiToFreq(midi) {
return 440 * Math.pow(2, (midi - 69) / 12);
}
function FullscreenDrumPad({ moduleId, onClose }) {
const [activePad, setActivePad] = useState(-1);
const hitPad = useCallback((pad, idx) => {
triggerKeyboard(moduleId, midiToFreq(pad.note), true);
setActivePad(idx);
setTimeout(() => {
triggerKeyboard(moduleId, midiToFreq(pad.note), false);
setActivePad(-1);
}, 150);
}, [moduleId]);
return (
<div className="drumpad-fullscreen">
<div className="drumpad-fs-header">
<span className="drumpad-fs-title">🥁 Drum Pads</span>
<button className="drumpad-fs-close" onClick={onClose}></button>
</div>
<div className="drumpad-fs-grid">
{PAD_NOTES.map((pad, i) => (
<div
key={i}
className="drumpad-fs-pad"
style={{
background: activePad === i ? pad.color : `${pad.color}15`,
borderColor: activePad === i ? pad.color : `${pad.color}40`,
color: activePad === i ? '#000' : pad.color,
}}
onPointerDown={() => hitPad(pad, i)}
>
{pad.label}
<span className="pad-label">{i + 1}</span>
</div>
))}
</div>
</div>
);
}
export default function DrumPadWidget({ moduleId, fullscreen, onCloseFullscreen }) {
const [activePad, setActivePad] = useState(-1);
const hitPad = useCallback((pad, idx) => {
triggerKeyboard(moduleId, midiToFreq(pad.note), true);
setActivePad(idx);
setTimeout(() => {
triggerKeyboard(moduleId, midiToFreq(pad.note), false);
setActivePad(-1);
}, 150);
}, [moduleId]);
return (
<>
<div>
<div className="drumpad-grid">
{PAD_NOTES.map((pad, i) => (
<div
key={i}
className={`drumpad-pad ${activePad === i ? 'active' : ''}`}
style={{
background: activePad === i ? pad.color : `${pad.color}15`,
borderColor: `${pad.color}60`,
}}
onPointerDown={(e) => { e.stopPropagation(); hitPad(pad, i); }}
>
{pad.label}
</div>
))}
</div>
<div style={{ fontSize: 9, color: 'var(--text2)', textAlign: 'center', marginTop: 2 }}>
Tap pads to trigger
</div>
</div>
{fullscreen && createPortal(
<FullscreenDrumPad moduleId={moduleId} onClose={onCloseFullscreen} />,
document.body
)}
</>
);
}

View File

@@ -0,0 +1,181 @@
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { triggerKeyboard } from '../engine/audioEngine.js';
import { state } from '../engine/state.js';
const NOTE_NAMES = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B'];
const KEY_MAP = {
'z': 0, 's': 1, 'x': 2, 'd': 3, 'c': 4, 'v': 5, 'g': 6,
'b': 7, 'h': 8, 'n': 9, 'j': 10, 'm': 11, ',': 12,
'q': 12, '2': 13, 'w': 14, '3': 15, 'e': 16, 'r': 17,
'5': 18, 't': 19, '6': 20, 'y': 21, '7': 22, 'u': 23, 'i': 24,
};
function midiToFreq(midi) {
return 440 * Math.pow(2, (midi - 69) / 12);
}
// Fullscreen piano — 1 octave, big comfortable keys like a real piano app
function FullscreenPiano({ moduleId, initialOctave, onClose }) {
const [oct, setOct] = useState(initialOctave);
const [activeNotes, setActiveNotes] = useState(new Set());
const play = useCallback((semitone) => {
const midi = (oct + 1) * 12 + semitone;
triggerKeyboard(moduleId, midiToFreq(midi), true);
setActiveNotes(prev => new Set(prev).add(semitone));
}, [moduleId, oct]);
const stop = useCallback((semitone) => {
setActiveNotes(prev => {
const next = new Set(prev);
next.delete(semitone);
if (next.size === 0) triggerKeyboard(moduleId, 440, false);
return next;
});
}, [moduleId]);
// 1 octave: 7 white keys, 5 black keys
const whiteKeys = [
{ note: 0, name: 'C' },
{ note: 2, name: 'D' },
{ note: 4, name: 'E' },
{ note: 5, name: 'F' },
{ note: 7, name: 'G' },
{ note: 9, name: 'A' },
{ note: 11, name: 'B' },
];
// Black key positions relative to white key index (0-6)
const blackKeys = [
{ note: 1, name: 'C#', after: 0 },
{ note: 3, name: 'D#', after: 1 },
{ note: 6, name: 'F#', after: 3 },
{ note: 8, name: 'G#', after: 4 },
{ note: 10, name: 'A#', after: 5 },
];
return (
<div className="keyboard-fullscreen">
<div className="keyboard-fs-header">
<button className="keyboard-fs-close" onClick={onClose}></button>
<button className="keyboard-fs-oct-btn" onClick={() => setOct(o => Math.max(1, o - 1))}></button>
<span className="keyboard-fs-title">Octave {oct}</span>
<button className="keyboard-fs-oct-btn" onClick={() => setOct(o => Math.min(8, o + 1))}></button>
<div style={{ width: 36 }} />
</div>
<div className="keyboard-fs-keys">
{whiteKeys.map((k, i) => (
<div
key={k.note}
className={`keyboard-fs-white ${activeNotes.has(k.note) ? 'pressed' : ''}`}
onPointerDown={() => play(k.note)}
onPointerUp={() => stop(k.note)}
onPointerLeave={() => stop(k.note)}
onPointerCancel={() => stop(k.note)}
>
<span className="keyboard-fs-note-label">{k.name}{oct}</span>
</div>
))}
{blackKeys.map((k) => (
<div
key={k.note}
className={`keyboard-fs-black ${activeNotes.has(k.note) ? 'pressed' : ''}`}
style={{ left: `${(k.after + 0.65) * (100 / 7)}%`, width: `${(100 / 7) * 0.65}%` }}
onPointerDown={() => play(k.note)}
onPointerUp={() => stop(k.note)}
onPointerLeave={() => stop(k.note)}
onPointerCancel={() => stop(k.note)}
>
<span className="keyboard-fs-black-label">{k.name}</span>
</div>
))}
</div>
</div>
);
}
export default function KeyboardWidget({ moduleId, fullscreen, onCloseFullscreen }) {
const mod = state.modules.find(m => m.id === moduleId);
const octave = mod?.params?.octave ?? 4;
const activeKeys = useRef(new Set());
const playNote = useCallback((semitone) => {
const midi = (octave + 1) * 12 + semitone;
const freq = midiToFreq(midi);
triggerKeyboard(moduleId, freq, true);
}, [moduleId, octave]);
const stopNote = useCallback(() => {
triggerKeyboard(moduleId, 440, false);
}, [moduleId]);
useEffect(() => {
const handleDown = (e) => {
if (e.repeat) return;
const key = e.key.toLowerCase();
if (KEY_MAP[key] !== undefined && !activeKeys.current.has(key)) {
activeKeys.current.add(key);
playNote(KEY_MAP[key]);
}
};
const handleUp = (e) => {
const key = e.key.toLowerCase();
if (KEY_MAP[key] !== undefined) {
activeKeys.current.delete(key);
if (activeKeys.current.size === 0) stopNote();
}
};
window.addEventListener('keydown', handleDown);
window.addEventListener('keyup', handleUp);
return () => {
window.removeEventListener('keydown', handleDown);
window.removeEventListener('keyup', handleUp);
};
}, [playNote, stopNote]);
// Mini keyboard (1 octave)
const whites = [0, 2, 4, 5, 7, 9, 11];
const blacks = [1, 3, -1, 6, 8, 10];
return (
<>
<div style={{ padding: '2px 0' }}>
<svg viewBox="0 0 140 30" style={{ width: '100%', height: 30 }}>
{whites.map((note, i) => (
<rect key={`w${i}`} x={i * 20} y={0} width={19} height={28}
rx={1} fill="#222" stroke="#444" strokeWidth={0.5}
style={{ cursor: 'pointer' }}
onPointerDown={() => playNote(note)}
onPointerUp={stopNote}
/>
))}
{blacks.filter(n => n >= 0).map((note, i) => {
const pos = [1, 2, 4, 5, 6][i];
return (
<rect key={`b${i}`} x={pos * 20 - 6} y={0} width={12} height={18}
rx={1} fill="#111" stroke="#333" strokeWidth={0.5}
style={{ cursor: 'pointer' }}
onPointerDown={() => playNote(note)}
onPointerUp={stopNote}
/>
);
})}
</svg>
<div style={{ fontSize: 9, color: 'var(--text2)', textAlign: 'center', marginTop: 2 }}>
Z-M / Q-I keys · Oct {octave}
</div>
</div>
{fullscreen && createPortal(
<FullscreenPiano
moduleId={moduleId}
initialOctave={octave}
onClose={onCloseFullscreen}
/>,
document.body
)}
</>
);
}

View File

@@ -0,0 +1,150 @@
import React, { useRef, useCallback, useState } from 'react';
const SIZE = 32;
const RADIUS = 12;
const STROKE = 3;
const START_ANGLE = 225;
const END_ANGLE = -45;
const RANGE = 270; // degrees
function polarToCart(cx, cy, r, deg) {
const rad = (deg - 90) * Math.PI / 180;
return { x: cx + r * Math.cos(rad), y: cy + r * Math.sin(rad) };
}
function describeArc(cx, cy, r, startDeg, endDeg) {
const start = polarToCart(cx, cy, r, endDeg);
const end = polarToCart(cx, cy, r, startDeg);
const large = endDeg - startDeg <= 180 ? '0' : '1';
return `M ${start.x} ${start.y} A ${r} ${r} 0 ${large} 0 ${end.x} ${end.y}`;
}
export default function Knob({ value, min, max, onChange, color = 'var(--accent)', label, unit, formatValue, modulated = false, liveValue }) {
const ref = useRef(null);
const dragRef = useRef(null);
const inputRef = useRef(null);
const [editing, setEditing] = useState(false);
const [editText, setEditText] = useState('');
// Use liveValue for visual display when being modulated, base value for interaction
const displayNum = liveValue !== undefined ? liveValue : value;
const clampedDisplay = Math.max(min, Math.min(max, displayNum));
const norm = Math.max(0, Math.min(1, (clampedDisplay - min) / (max - min)));
const angleDeg = START_ANGLE - norm * RANGE;
const cx = SIZE / 2, cy = SIZE / 2;
const trackPath = describeArc(cx, cy, RADIUS, END_ANGLE, START_ANGLE);
const fillAngle = START_ANGLE - norm * RANGE;
const fillPath = norm > 0.001 ? describeArc(cx, cy, RADIUS, fillAngle, START_ANGLE) : '';
const dotPos = polarToCart(cx, cy, RADIUS - 4, angleDeg);
// Also show base value indicator when modulated
const baseNorm = Math.max(0, Math.min(1, (value - min) / (max - min)));
const baseAngle = START_ANGLE - baseNorm * RANGE;
const baseDotPos = polarToCart(cx, cy, RADIUS - 4, baseAngle);
const displayVal = formatValue ? formatValue(displayNum) :
displayNum >= 1000 ? `${(displayNum / 1000).toFixed(1)}k` :
displayNum >= 100 ? Math.round(displayNum) :
displayNum >= 1 ? displayNum.toFixed(1) :
displayNum.toFixed(3).replace(/0+$/, '').replace(/\.$/, '');
const handlePointerDown = useCallback((e) => {
if (editing) return;
e.preventDefault(); e.stopPropagation();
dragRef.current = { startY: e.clientY, startValue: value };
const handleMove = (me) => {
const dy = dragRef.current.startY - me.clientY;
const sensitivity = (max - min) / 200;
let newVal = dragRef.current.startValue + dy * sensitivity;
newVal = Math.max(min, Math.min(max, newVal));
if (max - min > 10 && Number.isInteger(min) && Number.isInteger(max)) {
newVal = Math.round(newVal);
}
onChange(newVal);
};
const handleUp = () => {
window.removeEventListener('pointermove', handleMove);
window.removeEventListener('pointerup', handleUp);
dragRef.current = null;
};
window.addEventListener('pointermove', handleMove);
window.addEventListener('pointerup', handleUp);
}, [value, min, max, onChange, editing]);
const handleWheel = useCallback((e) => {
if (editing) return;
e.preventDefault(); e.stopPropagation();
const step = (max - min) / 100;
const newVal = Math.max(min, Math.min(max, value - Math.sign(e.deltaY) * step));
onChange(newVal);
}, [value, min, max, onChange, editing]);
// Double-click: open inline text input
const handleDoubleClick = useCallback((e) => {
e.preventDefault(); e.stopPropagation();
setEditText(String(typeof displayVal === 'number' ? displayVal : value));
setEditing(true);
// Focus input after render
setTimeout(() => inputRef.current?.focus(), 0);
}, [value, displayVal]);
const commitEdit = useCallback(() => {
const parsed = parseFloat(editText);
if (!isNaN(parsed)) {
const clamped = Math.max(min, Math.min(max, parsed));
onChange(clamped);
}
setEditing(false);
}, [editText, min, max, onChange]);
const handleInputKeyDown = useCallback((e) => {
e.stopPropagation();
if (e.key === 'Enter') {
commitEdit();
} else if (e.key === 'Escape') {
setEditing(false);
}
}, [commitEdit]);
const handleInputBlur = useCallback(() => {
commitEdit();
}, [commitEdit]);
if (editing) {
return (
<div className="knob-container knob-editing" onWheel={(e) => e.stopPropagation()}>
<input
ref={inputRef}
className="knob-input"
type="text"
value={editText}
onChange={(e) => setEditText(e.target.value)}
onKeyDown={handleInputKeyDown}
onBlur={handleInputBlur}
onPointerDown={(e) => e.stopPropagation()}
/>
</div>
);
}
return (
<div className={`knob-container ${modulated ? 'knob-modulated' : ''}`} onWheel={handleWheel} onDoubleClick={handleDoubleClick}>
<svg className="knob-svg" viewBox={`0 0 ${SIZE} ${SIZE}`}
onPointerDown={handlePointerDown} ref={ref}>
{/* Modulation glow ring */}
{modulated && (
<circle className="knob-mod-ring" cx={cx} cy={cy} r={RADIUS + 1} style={{ stroke: color }} />
)}
<path className="knob-track" d={trackPath} />
{fillPath && <path className="knob-fill" d={fillPath} style={{ stroke: color }} />}
{/* Ghost dot at base value position when modulated */}
{liveValue !== undefined && (
<circle className="knob-base-dot" cx={baseDotPos.x} cy={baseDotPos.y} r={1.5} />
)}
<circle className="knob-dot" cx={dotPos.x} cy={dotPos.y} r={2} />
</svg>
</div>
);
}

View File

@@ -0,0 +1,16 @@
export default function MobileTabBar({ tabs, activeTab, onTabChange }) {
return (
<nav className="mobile-tab-bar">
{tabs.map(tab => (
<button
key={tab.id}
className={`mobile-tab ${activeTab === tab.id ? 'active' : ''}`}
onClick={() => onTabChange(tab.id)}
>
<span className="mobile-tab-icon">{tab.icon}</span>
<span className="mobile-tab-label">{tab.label}</span>
</button>
))}
</nav>
);
}

View File

@@ -0,0 +1,299 @@
import React, { useCallback, useState, useEffect, useRef } from 'react';
import { getModuleDef } from '../engine/moduleRegistry.js';
import { state, removeModule, updateModuleParam, isPortConnected, emit } from '../engine/state.js';
import { updateParam, getAudioNode } from '../engine/audioEngine.js';
import Knob from './Knob.jsx';
import ScopeDisplay from './ScopeDisplay.jsx';
import KeyboardWidget from './KeyboardWidget.jsx';
import DrumPadWidget from './DrumPadWidget.jsx';
import SequencerWidget from './SequencerWidget.jsx';
import PianoRollWidget from './PianoRollWidget.jsx';
// Dynamic module widths for sequencer/pianoroll based on step/bar count
function getModuleWidth(mod, type) {
if (type === 'sequencer') {
const numSteps = parseInt(mod?.params?.steps || '16');
return Math.max(200, numSteps * 18 + 20); // CELL_W=18 + padding
}
if (type === 'pianoroll') {
const bars = parseInt(mod?.params?.bars || '4');
const totalBeats = bars * 4;
return 24 + totalBeats * 30 + 20; // KEY_W + beats*BEAT_PX + padding
}
return undefined;
}
// Map input port names → the param name they modulate (for visual feedback)
const PORT_TO_PARAM = {
filter: { cutoff: 'frequency' },
oscillator: { freq: 'frequency', detune: 'detune' },
vca: { cv: 'gain' },
};
// Compute a simulated LFO waveform value at time t (seconds)
function simulateLFO(waveform, phase) {
switch (waveform) {
case 'sine': return Math.sin(2 * Math.PI * phase);
case 'triangle': return 1 - 4 * Math.abs((phase % 1) - 0.5);
case 'sawtooth': return 2 * (phase % 1) - 1;
case 'square': return (phase % 1) < 0.5 ? 1 : -1;
default: return Math.sin(2 * Math.PI * phase);
}
}
export default function ModuleNode({ mod, zoom, onStartConnect, onPortPosition }) {
const def = getModuleDef(mod.type);
if (!def) return null;
const isSelected = state.selectedModuleId === mod.id;
const [fullscreen, setFullscreen] = useState(false);
// Merge default params
const params = { ...Object.fromEntries(Object.entries(def.params).map(([k, v]) => [k, v.default])), ...mod.params };
// Find which params are being modulated (have an incoming connection on their corresponding port)
const modulatedParams = new Set();
const portMap = PORT_TO_PARAM[mod.type] || {};
for (const conn of state.connections) {
if (conn.to.moduleId === mod.id && portMap[conn.to.port]) {
modulatedParams.add(portMap[conn.to.port]);
}
}
// ==================== Live modulation visualization (LFO + Envelope + any CV) ====================
const [liveValues, setLiveValues] = useState({});
const rafRef = useRef(null);
const startTimeRef = useRef(performance.now() / 1000);
useEffect(() => {
if (modulatedParams.size === 0) {
setLiveValues({});
return;
}
let frameCount = 0;
const tick = () => {
frameCount++;
rafRef.current = requestAnimationFrame(tick);
if (frameCount % 4 !== 0) return;
const t = performance.now() / 1000 - startTimeRef.current;
const newValues = {};
for (const conn of state.connections) {
if (conn.to.moduleId !== mod.id) continue;
const paramName = portMap[conn.to.port];
if (!paramName) continue;
const srcMod = state.modules.find(m => m.id === conn.from.moduleId);
if (!srcMod) continue;
if (srcMod.type === 'lfo') {
// LFO: simulate waveform for smooth visual
const lfoDef = getModuleDef('lfo');
const lfoP = { ...Object.fromEntries(Object.entries(lfoDef.params).map(([k, v]) => [k, v.default])), ...srcMod.params };
const freq = lfoP.frequency;
const amp = lfoP.amplitude;
const waveform = lfoP.waveform;
const phase = (t * freq) % 1;
const lfoVal = simulateLFO(waveform, phase) * amp;
const baseValue = params[paramName];
let scale;
if (mod.type === 'oscillator' && paramName === 'frequency') scale = baseValue * 0.5;
else if (mod.type === 'filter' && paramName === 'frequency') scale = baseValue;
else if (mod.type === 'vca' && paramName === 'gain') scale = 1;
else scale = baseValue || 1;
newValues[paramName] = baseValue + lfoVal * scale;
} else if (srcMod.type === 'envelope') {
// Envelope: read the actual audio node gain value for real-time display
const audioEntry = getAudioNode(mod.id);
if (audioEntry?.node?.gain) {
const currentGain = audioEntry.node.gain.value;
newValues[paramName] = currentGain;
}
}
}
setLiveValues(newValues);
};
rafRef.current = requestAnimationFrame(tick);
return () => {
if (rafRef.current) cancelAnimationFrame(rafRef.current);
};
}, [mod.id, mod.type, modulatedParams.size]);
const handleParamChange = useCallback((name, value) => {
updateModuleParam(mod.id, name, value);
updateParam(mod.id, name, value);
}, [mod.id]);
const handleHeaderDown = useCallback((e) => {
if (e.button !== 0) return;
e.stopPropagation();
state.selectedModuleId = mod.id;
state.dragging = {
moduleId: mod.id,
offsetX: e.clientX / zoom - mod.x,
offsetY: e.clientY / zoom - mod.y,
};
emit();
}, [mod, zoom]);
const handleDelete = useCallback((e) => {
e.stopPropagation();
removeModule(mod.id);
}, [mod.id]);
const handlePortMouseDown = useCallback((e, portName, direction) => {
e.stopPropagation(); e.preventDefault();
const portDef = direction === 'output'
? def.outputs.find(p => p.name === portName)
: def.inputs.find(p => p.name === portName);
if (!portDef) return;
const rect = e.currentTarget.getBoundingClientRect();
onStartConnect({
moduleId: mod.id,
port: portName,
portType: portDef.type,
direction,
startX: rect.left + rect.width / 2,
startY: rect.top + rect.height / 2,
});
}, [mod.id, def, onStartConnect]);
// Report port positions for wire rendering
const portRef = useCallback((el, portName, direction) => {
if (el) {
onPortPosition(mod.id, portName, direction, el);
}
}, [mod.id, onPortPosition]);
return (
<div
className={`module ${isSelected ? 'selected' : ''}`}
style={{
left: mod.x * zoom, top: mod.y * zoom,
transform: `scale(${zoom})`, transformOrigin: 'top left',
...(mod.type === 'pianoroll' ? { width: getModuleWidth(mod, 'pianoroll') } : mod.type === 'sequencer' ? { width: getModuleWidth(mod, 'sequencer') } : {}),
}}
data-module-id={mod.id}
onPointerDown={(e) => {
// Don't deselect when clicking inside a module
e.stopPropagation();
state.selectedModuleId = mod.id; emit();
}}
>
<div className="module-header" onPointerDown={handleHeaderDown}>
<span className="type-icon">{def.icon}</span>
<span className="type-name">{def.name}</span>
{(mod.type === 'keyboard' || mod.type === 'drumpad') && (
<button
className="expand-btn"
onClick={(e) => { e.stopPropagation(); setFullscreen(true); }}
title="Pantalla completa"
></button>
)}
<button className="close-btn" onClick={handleDelete}></button>
</div>
<div className="module-body">
{/* Input ports */}
{def.inputs.map(port => (
<div key={port.name} className="port-row input">
<div
className={`port-dot ${port.type} ${isPortConnected(mod.id, port.name, 'input') ? 'connected' : ''}`}
ref={el => portRef(el, port.name, 'input')}
data-module-id={mod.id}
data-port-name={port.name}
data-port-direction="input"
data-port-type={port.type}
onPointerDown={e => handlePortMouseDown(e, port.name, 'input')}
/>
<span className="port-label">{port.label}</span>
</div>
))}
{/* Parameters */}
{Object.entries(def.params).map(([name, paramDef]) => {
if (paramDef.type === 'knob') {
const color = paramDef.unit === 'Hz' ? 'var(--accent)' :
paramDef.unit === 'dB' ? 'var(--green)' :
paramDef.unit === 's' ? 'var(--purple)' : 'var(--accent)';
return (
<div key={name} className="param-row">
<span className="param-label">{paramDef.label}</span>
<Knob
value={params[name]}
min={paramDef.min}
max={paramDef.max}
onChange={v => handleParamChange(name, v)}
color={color}
modulated={modulatedParams.has(name)}
liveValue={liveValues[name]}
/>
<span className={`param-value ${liveValues[name] !== undefined ? 'param-value-live' : ''}`}>
{(() => {
const v = liveValues[name] !== undefined ? liveValues[name] : params[name];
const s = v >= 1000 ? `${(v / 1000).toFixed(1)}k` :
v >= 100 ? Math.round(v) :
v >= 1 ? Number(v).toFixed(1) :
Number(v).toFixed(3).replace(/0+$/, '').replace(/\.$/, '');
return s;
})()}
{paramDef.unit ? ` ${paramDef.unit}` : ''}
</span>
</div>
);
}
if (paramDef.type === 'select') {
return (
<div key={name} className="param-row">
<span className="param-label">{paramDef.label}</span>
<select className="param-select" value={params[name]}
onChange={e => handleParamChange(name, e.target.value)}>
{paramDef.options.map(opt => <option key={opt} value={opt}>{opt}</option>)}
</select>
</div>
);
}
return null;
})}
{/* Scope display */}
{mod.type === 'scope' && <ScopeDisplay moduleId={mod.id} />}
{/* Keyboard widget */}
{mod.type === 'keyboard' && <KeyboardWidget moduleId={mod.id} fullscreen={fullscreen} onCloseFullscreen={() => setFullscreen(false)} />}
{/* Drum Pad widget */}
{mod.type === 'drumpad' && <DrumPadWidget moduleId={mod.id} fullscreen={fullscreen} onCloseFullscreen={() => setFullscreen(false)} />}
{/* Sequencer widget */}
{mod.type === 'sequencer' && <SequencerWidget moduleId={mod.id} />}
{/* Piano Roll widget */}
{mod.type === 'pianoroll' && <PianoRollWidget moduleId={mod.id} />}
{/* Output ports */}
{def.outputs.map(port => (
<div key={port.name} className="port-row output">
<div
className={`port-dot ${port.type} ${isPortConnected(mod.id, port.name, 'output') ? 'connected' : ''}`}
ref={el => portRef(el, port.name, 'output')}
data-module-id={mod.id}
data-port-name={port.name}
data-port-direction="output"
data-port-type={port.type}
onPointerDown={e => handlePortMouseDown(e, port.name, 'output')}
/>
<span className="port-label">{port.label}</span>
</div>
))}
</div>
</div>
);
}

View File

@@ -0,0 +1,24 @@
import React from 'react';
import { getModulesByCategory } from '../engine/moduleRegistry.js';
export default function ModulePalette({ onAddModule }) {
const categories = getModulesByCategory();
return (
<div className="palette">
<div className="palette-title">Modules</div>
{Object.entries(categories).map(([cat, modules]) => (
<React.Fragment key={cat}>
<div className="palette-title" style={{ marginTop: 6, color: 'var(--text2)', fontSize: 8 }}>{cat}</div>
{modules.map(def => (
<div key={def.type} className="palette-item"
onClick={() => onAddModule(def.type)}>
<span className="p-icon">{def.icon}</span>
<span className="p-name">{def.name}</span>
</div>
))}
</React.Fragment>
))}
</div>
);
}

View File

@@ -0,0 +1,457 @@
import React, { useState, useEffect, useRef, useCallback } from 'react';
import * as Tone from 'tone';
import { state, updateModuleParam, emit } from '../engine/state.js';
import { setSequencerSignals, subscribeTick, unsubscribeTick, MASTER_TICK_RATE } from '../engine/audioEngine.js';
import { parseMidi } from '../utils/midiParser.js';
const NOTE_NAMES = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B'];
const BLACK_KEYS = [1, 3, 6, 8, 10];
function midiToFreq(midi) { return 440 * Math.pow(2, (midi - 69) / 12); }
function isBlack(midi) { return BLACK_KEYS.includes(midi % 12); }
// Super Mario Bros - Overworld Theme (NES, 1985)
// BPM ~200, swing 8ths feel. Each beat = 1 quarter note.
// s = eighth note unit (0.5 beats)
const s = 0.5;
const MARIO_MELODY = [
// Bar 1: E5 E5 . E5 . C5 E5 .
{ note: 76, start: 0, duration: s }, // E5
{ note: 76, start: 1*s, duration: s }, // E5
// rest
{ note: 76, start: 3*s, duration: s }, // E5
// rest
{ note: 72, start: 5*s, duration: s }, // C5
{ note: 76, start: 6*s, duration: 2*s }, // E5
// Bar 2: G5 . . . G4 . . .
{ note: 79, start: 8*s, duration: 2*s }, // G5
{ note: 67, start: 12*s, duration: 2*s }, // G4
// Bar 3: C5 . . G4 . . E4 . .
{ note: 72, start: 16*s, duration: s }, // C5
{ note: 67, start: 18*s, duration: s }, // G4
{ note: 64, start: 20*s, duration: s }, // E4
// Bar 4: . A4 . B4 . Bb4 A4 .
{ note: 69, start: 22*s, duration: s }, // A4
{ note: 71, start: 24*s, duration: s }, // B4
{ note: 70, start: 25*s, duration: s }, // Bb4
{ note: 69, start: 26*s, duration: s }, // A4
// Bar 5: G4 E5 G5 A5 . F5 G5 .
{ note: 67, start: 28*s, duration: 0.75 }, // G4 (triplet feel)
{ note: 76, start: 30*s, duration: 0.75 }, // E5
{ note: 79, start: 32*s, duration: s }, // G5
{ note: 81, start: 33*s, duration: s }, // A5
{ note: 77, start: 35*s, duration: s }, // F5
{ note: 79, start: 36*s, duration: s }, // G5
// Bar 6: . E5 . C5 D5 B4 . .
{ note: 76, start: 38*s, duration: s }, // E5
{ note: 72, start: 40*s, duration: s }, // C5
{ note: 74, start: 41*s, duration: s }, // D5
{ note: 71, start: 42*s, duration: s }, // B4
// Bar 7: . . C5 . . G4 . . E4
{ note: 72, start: 44*s, duration: s }, // C5
{ note: 67, start: 46*s, duration: s }, // G4
{ note: 64, start: 48*s, duration: s }, // E4
// Bar 8: . A4 . B4 . Bb4 A4 .
{ note: 69, start: 50*s, duration: s }, // A4
{ note: 71, start: 52*s, duration: s }, // B4
{ note: 70, start: 53*s, duration: s }, // Bb4
{ note: 69, start: 54*s, duration: s }, // A4
// Bar 9: G4 E5 G5 A5 . F5 G5 .
{ note: 67, start: 56*s, duration: 0.75 }, // G4
{ note: 76, start: 58*s, duration: 0.75 }, // E5
{ note: 79, start: 60*s, duration: s }, // G5
{ note: 81, start: 61*s, duration: s }, // A5
{ note: 77, start: 63*s, duration: s }, // F5
{ note: 79, start: 64*s, duration: s }, // G5
// Bar 10: . E5 . C5 D5 B4
{ note: 76, start: 66*s, duration: s }, // E5
{ note: 72, start: 68*s, duration: s }, // C5
{ note: 74, start: 69*s, duration: s }, // D5
{ note: 71, start: 70*s, duration: 2*s }, // B4
];
const BEAT_PX = 30; // pixels per beat — constant density regardless of bar count
const ROLL_H = 200;
const KEY_W = 24;
const MIN_NOTE = 48; // C3
const MAX_NOTE = 84; // C6
const NOTE_RANGE = MAX_NOTE - MIN_NOTE;
const ROW_H = ROLL_H / NOTE_RANGE;
export default function PianoRollWidget({ moduleId }) {
const mod = state.modules.find(m => m.id === moduleId);
const canvasRef = useRef(null);
const [tool, setTool] = useState('draw'); // 'draw' | 'erase'
const drawingRef = useRef(null);
const rafRef = useRef(null);
const playPosRef = useRef(-1);
const midiInputRef = useRef(null);
const bpm = mod?.params?.bpm ?? 140;
const bars = parseInt(mod?.params?.bars || '4');
const loop = mod?.params?.loop !== 'off';
const totalBeats = bars * 4;
// Init notes
if (!mod?.params?._notes) {
if (mod) mod.params._notes = [...MARIO_MELODY];
}
const notes = mod?.params?._notes || MARIO_MELODY;
const notesRef = useRef(notes);
notesRef.current = notes;
const rollW = KEY_W + totalBeats * BEAT_PX;
const beatW = BEAT_PX;
// Draw the piano roll
const draw = useCallback(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext('2d');
const w = canvas.width;
const h = canvas.height;
ctx.fillStyle = '#06060e';
ctx.fillRect(0, 0, w, h);
// Piano keys
for (let i = 0; i < NOTE_RANGE; i++) {
const midi = MAX_NOTE - 1 - i;
const y = i * ROW_H;
const black = isBlack(midi);
// Row background
ctx.fillStyle = black ? '#0a0a16' : '#0e0e1a';
ctx.fillRect(KEY_W, y, w - KEY_W, ROW_H);
// Grid lines
ctx.strokeStyle = '#151525';
ctx.lineWidth = 0.5;
ctx.beginPath();
ctx.moveTo(KEY_W, y); ctx.lineTo(w, y);
ctx.stroke();
// Key label
ctx.fillStyle = black ? '#1a1a30' : '#222240';
ctx.fillRect(0, y, KEY_W, ROW_H);
ctx.strokeStyle = '#0a0a14';
ctx.strokeRect(0, y, KEY_W, ROW_H);
if (midi % 12 === 0) { // C notes
ctx.fillStyle = '#4466aa';
ctx.font = '7px monospace';
ctx.textAlign = 'center';
ctx.fillText(`C${Math.floor(midi / 12) - 1}`, KEY_W / 2, y + ROW_H / 2 + 2.5);
}
}
// Beat grid lines
for (let b = 0; b <= totalBeats; b++) {
const x = KEY_W + b * beatW;
ctx.strokeStyle = b % 4 === 0 ? '#2a2a50' : b % 1 === 0 ? '#151525' : '#101020';
ctx.lineWidth = b % 4 === 0 ? 1 : 0.5;
ctx.beginPath();
ctx.moveTo(x, 0); ctx.lineTo(x, h);
ctx.stroke();
}
// Draw notes
const currentNotes = notesRef.current;
for (const n of currentNotes) {
if (n.note < MIN_NOTE || n.note >= MAX_NOTE) continue;
const row = MAX_NOTE - 1 - n.note;
const x = KEY_W + n.start * beatW;
const nw = n.duration * beatW;
const y = row * ROW_H;
// Note body
const gradient = ctx.createLinearGradient(x, y, x, y + ROW_H);
gradient.addColorStop(0, '#00ccff');
gradient.addColorStop(1, '#0066aa');
ctx.fillStyle = gradient;
ctx.fillRect(x + 0.5, y + 0.5, Math.max(nw - 1, 2), ROW_H - 1);
// Note border
ctx.strokeStyle = '#00e5ff';
ctx.lineWidth = 0.5;
ctx.strokeRect(x + 0.5, y + 0.5, Math.max(nw - 1, 2), ROW_H - 1);
// Note label for wider notes
if (nw > 15) {
ctx.fillStyle = '#fff';
ctx.font = '6px monospace';
ctx.textAlign = 'left';
ctx.fillText(NOTE_NAMES[n.note % 12], x + 2, y + ROW_H / 2 + 2);
}
}
// Playhead
const currentPlayPos = playPosRef.current;
if (currentPlayPos >= 0 && currentPlayPos < totalBeats) {
const px = KEY_W + currentPlayPos * beatW;
ctx.strokeStyle = '#ff6644';
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(px, 0); ctx.lineTo(px, h);
ctx.stroke();
// Glow
ctx.strokeStyle = 'rgba(255,102,68,0.2)';
ctx.lineWidth = 6;
ctx.beginPath();
ctx.moveTo(px, 0); ctx.lineTo(px, h);
ctx.stroke();
}
// Currently drawing note preview
if (drawingRef.current) {
const d = drawingRef.current;
const row = MAX_NOTE - 1 - d.note;
const x = KEY_W + d.start * beatW;
const nw = d.duration * beatW;
ctx.fillStyle = 'rgba(0,229,255,0.3)';
ctx.fillRect(x, row * ROW_H, Math.max(nw, 2), ROW_H);
}
}, [totalBeats, beatW, rollW]);
// Animation loop
useEffect(() => {
const animate = () => {
draw();
rafRef.current = requestAnimationFrame(animate);
};
animate();
return () => { if (rafRef.current) cancelAnimationFrame(rafRef.current); };
}, [draw]);
// Subscribe to global master clock for playback
const bpmRef = useRef(bpm);
const loopRef = useRef(loop);
const totalBeatsRef = useRef(totalBeats);
bpmRef.current = bpm;
loopRef.current = loop;
totalBeatsRef.current = totalBeats;
useEffect(() => {
if (!state.isRunning) {
unsubscribeTick(`pr-${moduleId}`);
playPosRef.current = -1;
return;
}
let currentNote = null;
let lastQuantPos = -1;
subscribeTick(`pr-${moduleId}`, (time, ticks) => {
const currentBpm = bpmRef.current;
const currentLoop = loopRef.current;
const currentTotalBeats = totalBeatsRef.current;
// Convert ticks to beats: each beat = MASTER_TICK_RATE * 60 / bpm ticks
// Position in sixteenths: ticks / (ticksPerSixteenth)
const ticksPerBeat = MASTER_TICK_RATE * 60 / currentBpm;
const rawPos = ticks / ticksPerBeat; // in beats
const pos = currentLoop ? rawPos % currentTotalBeats : rawPos;
const quantPos = Math.floor(pos * 4) / 4;
if (quantPos === lastQuantPos) return;
const looped = lastQuantPos >= 0 && quantPos < lastQuantPos;
lastQuantPos = quantPos;
if (!currentLoop && rawPos >= currentTotalBeats) {
if (currentNote) {
setSequencerSignals(moduleId, 0, false);
currentNote = null;
}
playPosRef.current = -1;
return;
}
playPosRef.current = pos;
if (looped && currentNote) {
setSequencerSignals(moduleId, 0, false);
currentNote = null;
}
const allNotes = notesRef.current;
const activeNote = allNotes.find(n => quantPos >= n.start && quantPos < n.start + n.duration);
if (activeNote) {
if (!currentNote || currentNote.note !== activeNote.note || currentNote.start !== activeNote.start) {
setSequencerSignals(moduleId, midiToFreq(activeNote.note), true);
currentNote = activeNote;
}
} else {
if (currentNote) {
setSequencerSignals(moduleId, 0, false);
currentNote = null;
}
}
});
return () => {
unsubscribeTick(`pr-${moduleId}`);
};
}, [state.isRunning, moduleId]);
// Mouse interaction for drawing/erasing notes
const handleMouseDown = useCallback((e) => {
const canvas = canvasRef.current;
if (!canvas) return;
const rect = canvas.getBoundingClientRect();
// Account for CSS transform scale (zoom) — rect is visual size, canvas is logical size
const scaleX = canvas.width / rect.width;
const scaleY = canvas.height / rect.height;
const mx = (e.clientX - rect.left) * scaleX;
const my = (e.clientY - rect.top) * scaleY;
if (mx < KEY_W) return; // Clicked on piano keys
const beat = (mx - KEY_W) / beatW;
const noteIdx = MAX_NOTE - 1 - Math.floor(my / ROW_H);
if (noteIdx < MIN_NOTE || noteIdx >= MAX_NOTE) return;
const snappedBeat = Math.floor(beat * 4) / 4; // Snap to 16th
if (tool === 'erase') {
// Remove any note at this position
const filtered = notes.filter(n =>
!(n.note === noteIdx && snappedBeat >= n.start && snappedBeat < n.start + n.duration)
);
if (filtered.length !== notes.length) {
mod.params._notes = filtered;
notesRef.current = filtered;
emit();
}
} else {
// Check if clicking on existing note — remove it
const existing = notes.findIndex(n =>
n.note === noteIdx && snappedBeat >= n.start && snappedBeat < n.start + n.duration
);
if (existing >= 0) {
const filtered = [...notes];
filtered.splice(existing, 1);
mod.params._notes = filtered;
notesRef.current = filtered;
emit();
return;
}
// Start drawing new note
drawingRef.current = { note: noteIdx, start: snappedBeat, duration: 0.25 };
const handleMove = (me) => {
if (!drawingRef.current) return;
const mmx = (me.clientX - rect.left) * scaleX;
const endBeat = Math.max(drawingRef.current.start + 0.25,
Math.ceil(((mmx - KEY_W) / beatW) * 4) / 4);
drawingRef.current.duration = endBeat - drawingRef.current.start;
};
const handleUp = () => {
if (drawingRef.current) {
const newNote = { ...drawingRef.current };
newNote.duration = Math.max(0.25, Math.min(newNote.duration, totalBeats - newNote.start));
const updated = [...notes, newNote];
mod.params._notes = updated;
notesRef.current = updated;
drawingRef.current = null;
emit();
}
window.removeEventListener('pointermove', handleMove);
window.removeEventListener('pointerup', handleUp);
};
window.addEventListener('pointermove', handleMove);
window.addEventListener('pointerup', handleUp);
}
}, [tool, notes, beatW, totalBeats, mod]);
const handleMidiImport = useCallback(async (e) => {
const file = e.target.files[0];
if (!file) return;
try {
const arrayBuffer = await file.arrayBuffer();
const midi = parseMidi(arrayBuffer);
if (midi.notes.length === 0) return;
// Update BPM if detected
if (midi.bpm && mod) {
mod.params.bpm = midi.bpm;
}
// Auto-fit bars to cover all notes
const maxBeat = Math.max(...midi.notes.map(n => n.start + n.duration));
const neededBars = Math.ceil(maxBeat / 4);
const fitBars = [1, 2, 4, 8].find(b => b >= neededBars) || 8;
if (mod) mod.params.bars = String(fitBars);
// Set notes
if (mod) {
mod.params._notes = midi.notes;
notesRef.current = midi.notes;
emit();
}
} catch (err) {
console.error('[PianoRoll] MIDI import failed:', err);
}
e.target.value = '';
}, [mod]);
return (
<div style={{ width: rollW }}>
{/* Mini toolbar */}
<div style={{ display: 'flex', gap: 4, marginBottom: 3 }}>
<button
style={{
padding: '1px 6px', fontSize: 9, border: '1px solid',
borderColor: tool === 'draw' ? '#00e5ff' : '#333',
background: tool === 'draw' ? '#00e5ff' : '#111',
color: tool === 'draw' ? '#000' : '#888',
borderRadius: 3, cursor: 'pointer', fontFamily: 'inherit',
}}
onClick={() => setTool('draw')}
> Draw</button>
<button
style={{
padding: '1px 6px', fontSize: 9, border: '1px solid',
borderColor: tool === 'erase' ? '#ff4466' : '#333',
background: tool === 'erase' ? '#ff4466' : '#111',
color: tool === 'erase' ? '#000' : '#888',
borderRadius: 3, cursor: 'pointer', fontFamily: 'inherit',
}}
onClick={() => setTool('erase')}
> Erase</button>
<button
style={{
padding: '1px 6px', fontSize: 9, border: '1px solid #333',
background: '#111', color: '#888',
borderRadius: 3, cursor: 'pointer', fontFamily: 'inherit',
}}
onClick={() => midiInputRef.current?.click()}
>🎵 MIDI</button>
<input ref={midiInputRef} type="file" accept=".mid,.midi" style={{ display: 'none' }} onChange={handleMidiImport} />
<span style={{ fontSize: 8, color: '#555', marginLeft: 'auto', alignSelf: 'center' }}>
{notes.length} notes · {bars} bars
</span>
</div>
<canvas
ref={canvasRef}
width={rollW}
height={ROLL_H}
style={{ width: rollW, height: ROLL_H, borderRadius: 4, cursor: tool === 'draw' ? 'crosshair' : 'pointer' }}
onPointerDown={handleMouseDown}
/>
</div>
);
}

View File

@@ -0,0 +1,72 @@
import React, { useState } from 'react';
import { getPresets, savePreset, loadPreset, deletePreset } from '../engine/presets.js';
export default function PresetModal({ mode, onClose }) {
const [name, setName] = useState('');
const presets = getPresets();
const handleSave = () => {
if (!name.trim()) return;
savePreset(name.trim());
onClose();
};
const handleLoad = (presetName) => {
loadPreset(presetName);
onClose();
};
const handleDelete = (e, presetName) => {
e.stopPropagation();
deletePreset(presetName);
// Force re-render
setName(n => n + '');
};
return (
<div className="modal-overlay" onClick={onClose}>
<div className="modal" onClick={e => e.stopPropagation()}>
<h2>{mode === 'save' ? 'Save Preset' : 'Load Preset'}</h2>
{mode === 'save' && (
<>
<input
autoFocus
placeholder="Preset name..."
value={name}
onChange={e => setName(e.target.value)}
onKeyDown={e => e.key === 'Enter' && handleSave()}
/>
<div className="modal-actions">
<button onClick={onClose}>Cancel</button>
<button className="primary" onClick={handleSave}>Save</button>
</div>
</>
)}
{mode === 'load' && (
<>
<div className="preset-list">
{presets.length === 0 && (
<div style={{ color: 'var(--text2)', padding: 12, textAlign: 'center' }}>No presets saved yet</div>
)}
{presets.map(p => (
<div key={p.name} className="preset-item" onClick={() => handleLoad(p.name)}>
<span>{p.name}</span>
<span className="preset-date">{p.modules?.length || 0} modules</span>
<button
style={{ background: 'none', border: 'none', color: 'var(--red)', cursor: 'pointer', marginLeft: 8, fontSize: 12 }}
onClick={e => handleDelete(e, p.name)}
></button>
</div>
))}
</div>
<div className="modal-actions">
<button onClick={onClose}>Close</button>
</div>
</>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,112 @@
import React, { useRef, useEffect, useState } from 'react';
import { getAnalyserData } from '../engine/audioEngine.js';
// Zoom levels: how many samples to display (from a 2048-sample buffer)
// Fewer samples = zoomed in (more detail), more samples = zoomed out (more time visible)
const ZOOM_LEVELS = [64, 128, 256, 512, 1024, 2048];
const DEFAULT_ZOOM = 2; // index → 256 samples
export default function ScopeDisplay({ moduleId }) {
const canvasRef = useRef(null);
const rafRef = useRef(null);
const [zoomIdx, setZoomIdx] = useState(DEFAULT_ZOOM);
const zoomRef = useRef(ZOOM_LEVELS[DEFAULT_ZOOM]);
// Keep ref in sync so the draw loop picks it up without re-creating the effect
useEffect(() => { zoomRef.current = ZOOM_LEVELS[zoomIdx]; }, [zoomIdx]);
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext('2d');
const w = canvas.width = 160;
const h = canvas.height = 60;
let frameCount = 0;
const draw = () => {
frameCount++;
rafRef.current = requestAnimationFrame(draw);
// Throttle to ~30fps to reduce main thread pressure during playback
if (frameCount % 2 !== 0) return;
ctx.fillStyle = '#050510';
ctx.fillRect(0, 0, w, h);
// Grid lines
ctx.strokeStyle = '#151530';
ctx.lineWidth = 0.5;
ctx.beginPath();
ctx.moveTo(0, h / 2); ctx.lineTo(w, h / 2);
ctx.moveTo(0, h / 4); ctx.lineTo(w, h / 4);
ctx.moveTo(0, h * 3 / 4); ctx.lineTo(w, h * 3 / 4);
for (let x = w / 4; x < w; x += w / 4) {
ctx.moveTo(x, 0); ctx.lineTo(x, h);
}
ctx.stroke();
const data = getAnalyserData(moduleId);
if (data && data.length > 0) {
const samplesToShow = zoomRef.current;
// Center the window in the buffer
const offset = Math.max(0, Math.floor((data.length - samplesToShow) / 2));
const end = Math.min(data.length, offset + samplesToShow);
ctx.strokeStyle = '#00e5ff';
ctx.lineWidth = 1.5;
ctx.beginPath();
const count = end - offset;
const step = w / count;
for (let i = 0; i < count; i++) {
const y = h / 2 + data[offset + i] * h / 2 * -1;
if (i === 0) ctx.moveTo(0, y);
else ctx.lineTo(i * step, y);
}
ctx.stroke();
}
};
draw();
return () => { if (rafRef.current) cancelAnimationFrame(rafRef.current); };
}, [moduleId]);
const canZoomIn = zoomIdx > 0;
const canZoomOut = zoomIdx < ZOOM_LEVELS.length - 1;
return (
<div style={{ position: 'relative' }}>
<canvas ref={canvasRef} className="scope-canvas" />
<div style={{
position: 'absolute', bottom: 2, right: 2,
display: 'flex', gap: 2,
}}>
<button
onClick={() => canZoomOut && setZoomIdx(i => i + 1)}
disabled={!canZoomOut}
title="Zoom out (más tiempo)"
style={{
width: 18, height: 18, padding: 0,
background: canZoomOut ? '#1a1a3a' : '#0a0a15',
border: '1px solid #333', borderRadius: 3,
color: canZoomOut ? '#00e5ff' : '#333',
cursor: canZoomOut ? 'pointer' : 'default',
fontSize: 12, lineHeight: '16px', fontWeight: 'bold',
}}
></button>
<button
onClick={() => canZoomIn && setZoomIdx(i => i - 1)}
disabled={!canZoomIn}
title="Zoom in (más detalle)"
style={{
width: 18, height: 18, padding: 0,
background: canZoomIn ? '#1a1a3a' : '#0a0a15',
border: '1px solid #333', borderRadius: 3,
color: canZoomIn ? '#00e5ff' : '#333',
cursor: canZoomIn ? 'pointer' : 'default',
fontSize: 12, lineHeight: '16px', fontWeight: 'bold',
}}
>+</button>
</div>
</div>
);
}

View File

@@ -0,0 +1,185 @@
import React, { useState, useEffect, useRef, useCallback } from 'react';
import * as Tone from 'tone';
import { state, updateModuleParam, emit } from '../engine/state.js';
import { setSequencerSignals, getAudioNode, subscribeTick, unsubscribeTick, MASTER_TICK_RATE } from '../engine/audioEngine.js';
const NOTE_NAMES = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B'];
function midiToFreq(midi) { return 440 * Math.pow(2, (midi - 69) / 12); }
function noteLabel(midi) { return NOTE_NAMES[midi % 12] + Math.floor(midi / 12 - 1); }
const DEFAULT_STEPS = [
{ midi: 60, gate: true }, { midi: 63, gate: true }, { midi: 65, gate: false }, { midi: 67, gate: true },
{ midi: 70, gate: true }, { midi: 67, gate: true }, { midi: 63, gate: false }, { midi: 60, gate: true },
{ midi: 58, gate: true }, { midi: 60, gate: true }, { midi: 63, gate: false }, { midi: 65, gate: true },
{ midi: 67, gate: true }, { midi: 72, gate: true }, { midi: 70, gate: false }, { midi: 67, gate: true },
];
export default function SequencerWidget({ moduleId }) {
const mod = state.modules.find(m => m.id === moduleId);
const currentStepRef = useRef(-1);
const [visualStep, setVisualStep] = useState(-1);
const stepsRef = useRef(null);
const rafRef = useRef(null);
// Init steps data
const numSteps = parseInt(mod?.params?.steps || '16');
if (mod) {
if (!mod.params._steps) {
const initial = DEFAULT_STEPS.slice(0, numSteps);
while (initial.length < numSteps) initial.push({ midi: 60, gate: false });
mod.params._steps = initial;
} else if (mod.params._steps.length < numSteps) {
while (mod.params._steps.length < numSteps) {
mod.params._steps.push({ midi: 60, gate: false });
}
} else if (mod.params._steps.length > numSteps) {
mod.params._steps = mod.params._steps.slice(0, numSteps);
}
}
const steps = mod?.params?._steps || DEFAULT_STEPS;
stepsRef.current = steps;
const bpm = mod?.params?.bpm ?? 140;
// Visual update loop — decoupled from audio, uses RAF
useEffect(() => {
const tick = () => {
setVisualStep(currentStepRef.current);
rafRef.current = requestAnimationFrame(tick);
};
if (state.isRunning) {
rafRef.current = requestAnimationFrame(tick);
}
return () => {
if (rafRef.current) cancelAnimationFrame(rafRef.current);
};
}, [state.isRunning]);
// Subscribe to global master clock — derive step from elapsed time
const bpmRef = useRef(bpm);
const numStepsRef = useRef(numSteps);
bpmRef.current = bpm;
numStepsRef.current = numSteps;
useEffect(() => {
if (!state.isRunning) {
unsubscribeTick(`seq-${moduleId}`);
currentStepRef.current = -1;
setVisualStep(-1);
return;
}
let lastStepIdx = -1;
let lastGateOn = false;
subscribeTick(`seq-${moduleId}`, (time, ticks) => {
const currentBpm = bpmRef.current;
const currentNumSteps = numStepsRef.current;
// ticksPerStep = MASTER_TICK_RATE / sixteenthsPerSecond
// sixteenthsPerSecond = bpm * 4 / 60
const ticksPerStep = MASTER_TICK_RATE * 60 / (currentBpm * 4);
const stepIdx = Math.floor(ticks / ticksPerStep) % currentNumSteps;
if (stepIdx === lastStepIdx) return;
lastStepIdx = stepIdx;
// Turn off previous note at step boundary (no setTimeout needed)
if (lastGateOn) {
setSequencerSignals(moduleId, 0, false);
lastGateOn = false;
}
const s = stepsRef.current[stepIdx];
if (!s) return;
currentStepRef.current = stepIdx;
if (s.gate) {
setSequencerSignals(moduleId, midiToFreq(s.midi), true);
lastGateOn = true;
}
});
return () => {
unsubscribeTick(`seq-${moduleId}`);
};
}, [state.isRunning, moduleId]);
const toggleGate = (idx) => {
steps[idx] = { ...steps[idx], gate: !steps[idx].gate };
updateModuleParam(moduleId, '_steps', [...steps]);
stepsRef.current = steps;
};
const changeNote = (idx, delta) => {
const newMidi = Math.max(36, Math.min(96, steps[idx].midi + delta));
steps[idx] = { ...steps[idx], midi: newMidi };
updateModuleParam(moduleId, '_steps', [...steps]);
stepsRef.current = steps;
emit();
};
const CELL_W = 18;
const CELL_H = 50;
const W = CELL_W * numSteps;
const H = CELL_H + 16;
return (
<div style={{ width: W + 4, overflow: 'hidden' }}>
<svg viewBox={`0 0 ${W} ${H}`} style={{ width: W, height: H, display: 'block' }}>
{steps.slice(0, numSteps).map((s, i) => {
const x = i * CELL_W;
const isActive = i === visualStep;
const barHeight = ((s.midi - 36) / 60) * (CELL_H - 4);
return (
<g key={i}>
<rect x={x + 1} y={0} width={CELL_W - 2} height={CELL_H}
rx={2} fill={isActive ? '#1a2a40' : '#0c0c18'}
stroke={isActive ? '#00e5ff' : '#222'} strokeWidth={isActive ? 1.5 : 0.5}
/>
{s.gate && (
<rect x={x + 3} y={CELL_H - barHeight - 2} width={CELL_W - 6} height={barHeight}
rx={1}
fill={isActive ? '#00e5ff' : '#0088aa'}
opacity={0.9}
/>
)}
{!s.gate && (
<line x1={x + CELL_W / 2} y1={CELL_H / 2 - 3} x2={x + CELL_W / 2} y2={CELL_H / 2 + 3}
stroke="#333" strokeWidth={1.5} />
)}
<text x={x + CELL_W / 2} y={CELL_H + 10} textAnchor="middle"
fontSize={6} fill={s.gate ? '#88ccff' : '#333'} fontFamily="monospace">
{noteLabel(s.midi)}
</text>
<rect x={x} y={0} width={CELL_W} height={CELL_H / 3}
fill="transparent" style={{ cursor: 'pointer' }}
onClick={() => changeNote(i, 1)}
/>
<rect x={x} y={CELL_H / 3} width={CELL_W} height={CELL_H / 3}
fill="transparent" style={{ cursor: 'pointer' }}
onClick={() => toggleGate(i)}
/>
<rect x={x} y={CELL_H * 2 / 3} width={CELL_W} height={CELL_H / 3}
fill="transparent" style={{ cursor: 'pointer' }}
onClick={() => changeNote(i, -1)}
/>
</g>
);
})}
{visualStep >= 0 && (
<line
x1={visualStep * CELL_W + CELL_W / 2} y1={0}
x2={visualStep * CELL_W + CELL_W / 2} y2={CELL_H}
stroke="#00e5ff" strokeWidth={1} opacity={0.4}
/>
)}
</svg>
<div style={{ fontSize: 8, color: '#555', textAlign: 'center', marginTop: 2 }}>
top/bot: pitch · mid: toggle
</div>
</div>
);
}

View File

@@ -0,0 +1,74 @@
import React, { useState, useEffect } from 'react';
import { wirePath } from '../utils/bezier.js';
import { state, removeConnection } from '../engine/state.js';
import { disconnectWire } from '../engine/audioEngine.js';
import { getModuleDef, PORT_TYPE } from '../engine/moduleRegistry.js';
export default function WireLayer({ portPositions, tempWire, containerRef, zoom, camX, camY }) {
// Force a second render after DOM commit so getBoundingClientRect reads correct positions
// This fixes wires lagging behind after zoom, pan, or level re-entry
const [, refreshWires] = useState(0);
const connCount = state.connections.length;
const modCount = state.modules.length;
useEffect(() => {
const id = requestAnimationFrame(() => refreshWires(n => n + 1));
return () => cancelAnimationFrame(id);
}, [zoom, camX, camY, connCount, modCount]);
const getPortPos = (moduleId, portName, direction) => {
const key = `${moduleId}-${portName}-${direction}`;
const el = portPositions.current[key];
if (!el || !containerRef.current) return null;
const rect = el.getBoundingClientRect();
const containerRect = containerRef.current.getBoundingClientRect();
return {
x: rect.left + rect.width / 2 - containerRect.left,
y: rect.top + rect.height / 2 - containerRect.top,
};
};
const getPortType = (moduleId, portName, direction) => {
const mod = state.modules.find(m => m.id === moduleId);
if (!mod) return PORT_TYPE.AUDIO;
const def = getModuleDef(mod.type);
if (!def) return PORT_TYPE.AUDIO;
const ports = direction === 'output' ? def.outputs : def.inputs;
const port = ports.find(p => p.name === portName);
return port?.type || PORT_TYPE.AUDIO;
};
const handleWireClick = (conn) => {
disconnectWire(conn);
removeConnection(conn.id);
};
return (
<svg className="wires-svg">
{/* Existing connections */}
{state.connections.map(conn => {
const from = getPortPos(conn.from.moduleId, conn.from.port, 'output');
const to = getPortPos(conn.to.moduleId, conn.to.port, 'input');
if (!from || !to) return null;
const portType = getPortType(conn.from.moduleId, conn.from.port, 'output');
return (
<path
key={conn.id}
className={portType}
d={wirePath(from.x, from.y, to.x, to.y)}
style={{ pointerEvents: 'stroke', cursor: 'pointer' }}
onClick={() => handleWireClick(conn)}
/>
);
})}
{/* Temp wire while connecting */}
{tempWire && (
<path
className={`${tempWire.portType} temp`}
d={wirePath(tempWire.startX, tempWire.startY, tempWire.endX, tempWire.endY)}
/>
)}
</svg>
);
}