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:
52
packages/client/src/components/BottomSheet.jsx
Normal file
52
packages/client/src/components/BottomSheet.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
109
packages/client/src/components/DrumPadWidget.jsx
Normal file
109
packages/client/src/components/DrumPadWidget.jsx
Normal 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
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
181
packages/client/src/components/KeyboardWidget.jsx
Normal file
181
packages/client/src/components/KeyboardWidget.jsx
Normal 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
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
150
packages/client/src/components/Knob.jsx
Normal file
150
packages/client/src/components/Knob.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
16
packages/client/src/components/MobileTabBar.jsx
Normal file
16
packages/client/src/components/MobileTabBar.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
299
packages/client/src/components/ModuleNode.jsx
Normal file
299
packages/client/src/components/ModuleNode.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
24
packages/client/src/components/ModulePalette.jsx
Normal file
24
packages/client/src/components/ModulePalette.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
457
packages/client/src/components/PianoRollWidget.jsx
Normal file
457
packages/client/src/components/PianoRollWidget.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
72
packages/client/src/components/PresetModal.jsx
Normal file
72
packages/client/src/components/PresetModal.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
112
packages/client/src/components/ScopeDisplay.jsx
Normal file
112
packages/client/src/components/ScopeDisplay.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
185
packages/client/src/components/SequencerWidget.jsx
Normal file
185
packages/client/src/components/SequencerWidget.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
74
packages/client/src/components/WireLayer.jsx
Normal file
74
packages/client/src/components/WireLayer.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user