feat: add sequencer, piano roll modules with pre-composed chiptune melody
Add step sequencer (16-step with note/gate editing) and piano roll (canvas-based MIDI editor with draw/erase tools). Includes a Megaman-style melody in C minor. Chiptune preset now uses piano roll instead of keyboard. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -5,6 +5,8 @@ import { updateParam } from '../engine/audioEngine.js';
|
||||
import Knob from './Knob.jsx';
|
||||
import ScopeDisplay from './ScopeDisplay.jsx';
|
||||
import KeyboardWidget from './KeyboardWidget.jsx';
|
||||
import SequencerWidget from './SequencerWidget.jsx';
|
||||
import PianoRollWidget from './PianoRollWidget.jsx';
|
||||
|
||||
export default function ModuleNode({ mod, zoom, onStartConnect, onPortPosition }) {
|
||||
const def = getModuleDef(mod.type);
|
||||
@@ -65,7 +67,11 @@ export default function ModuleNode({ mod, zoom, onStartConnect, onPortPosition }
|
||||
return (
|
||||
<div
|
||||
className={`module ${isSelected ? 'selected' : ''}`}
|
||||
style={{ left: mod.x * zoom, top: mod.y * zoom, transform: `scale(${zoom})`, transformOrigin: 'top left' }}
|
||||
style={{
|
||||
left: mod.x * zoom, top: mod.y * zoom,
|
||||
transform: `scale(${zoom})`, transformOrigin: 'top left',
|
||||
...(mod.type === 'pianoroll' ? { width: 520 } : mod.type === 'sequencer' ? { width: 310 } : {}),
|
||||
}}
|
||||
data-module-id={mod.id}
|
||||
onPointerDown={(e) => {
|
||||
// Don't deselect when clicking inside a module
|
||||
@@ -142,6 +148,12 @@ export default function ModuleNode({ mod, zoom, onStartConnect, onPortPosition }
|
||||
{/* Keyboard widget */}
|
||||
{mod.type === 'keyboard' && <KeyboardWidget moduleId={mod.id} />}
|
||||
|
||||
{/* 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">
|
||||
|
||||
361
src/components/PianoRollWidget.jsx
Normal file
361
src/components/PianoRollWidget.jsx
Normal file
@@ -0,0 +1,361 @@
|
||||
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import * as Tone from 'tone';
|
||||
import { state, updateModuleParam, emit } from '../engine/state.js';
|
||||
import { setSequencerSignals } from '../engine/audioEngine.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); }
|
||||
|
||||
// Chiptune melody: Megaman-style theme in C minor
|
||||
// Format: { note: MIDI, start: beats, duration: beats }
|
||||
const MEGA_MELODY = [
|
||||
// Bar 1: Opening riff
|
||||
{ note: 72, start: 0, duration: 0.25 }, // C5
|
||||
{ note: 72, start: 0.5, duration: 0.25 },
|
||||
{ note: 75, start: 1, duration: 0.5 }, // Eb5
|
||||
{ note: 72, start: 1.75, duration: 0.25 },
|
||||
{ note: 70, start: 2, duration: 0.5 }, // Bb4
|
||||
{ note: 67, start: 2.75, duration: 0.25 }, // G4
|
||||
{ note: 65, start: 3, duration: 0.5 }, // F4
|
||||
{ note: 63, start: 3.5, duration: 0.5 }, // Eb4
|
||||
|
||||
// Bar 2: Response phrase
|
||||
{ note: 60, start: 4, duration: 0.5 }, // C4
|
||||
{ note: 63, start: 4.5, duration: 0.25 }, // Eb4
|
||||
{ note: 65, start: 5, duration: 0.25 }, // F4
|
||||
{ note: 67, start: 5.5, duration: 0.75 }, // G4
|
||||
{ note: 70, start: 6.5, duration: 0.5 }, // Bb4
|
||||
{ note: 72, start: 7, duration: 1 }, // C5
|
||||
|
||||
// Bar 3: Climb up
|
||||
{ note: 67, start: 8, duration: 0.25 }, // G4
|
||||
{ note: 70, start: 8.5, duration: 0.25 }, // Bb4
|
||||
{ note: 72, start: 9, duration: 0.25 }, // C5
|
||||
{ note: 75, start: 9.5, duration: 0.5 }, // Eb5
|
||||
{ note: 77, start: 10, duration: 0.5 }, // F5
|
||||
{ note: 79, start: 10.5, duration: 0.5 }, // G5
|
||||
{ note: 77, start: 11, duration: 0.25 }, // F5
|
||||
{ note: 75, start: 11.5, duration: 0.5 }, // Eb5
|
||||
|
||||
// Bar 4: Resolution
|
||||
{ note: 72, start: 12, duration: 0.5 }, // C5
|
||||
{ note: 70, start: 12.5, duration: 0.25 }, // Bb4
|
||||
{ note: 67, start: 13, duration: 0.5 }, // G4
|
||||
{ note: 63, start: 13.75, duration: 0.25 }, // Eb4
|
||||
{ note: 60, start: 14, duration: 1 }, // C4
|
||||
{ note: 60, start: 15.5, duration: 0.5 }, // C4 (pickup)
|
||||
];
|
||||
|
||||
const ROLL_W = 500;
|
||||
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 partRef = useRef(null);
|
||||
const [playPos, setPlayPos] = useState(-1);
|
||||
const [tool, setTool] = useState('draw'); // 'draw' | 'erase'
|
||||
const drawingRef = useRef(null);
|
||||
const rafRef = 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 = [...MEGA_MELODY];
|
||||
}
|
||||
const notes = mod?.params?._notes || MEGA_MELODY;
|
||||
const notesRef = useRef(notes);
|
||||
notesRef.current = notes;
|
||||
|
||||
const beatW = (ROLL_W - KEY_W) / totalBeats;
|
||||
|
||||
// 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
|
||||
if (playPos >= 0 && playPos < totalBeats) {
|
||||
const px = KEY_W + playPos * 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, playPos]);
|
||||
|
||||
// Animation loop
|
||||
useEffect(() => {
|
||||
const animate = () => {
|
||||
draw();
|
||||
rafRef.current = requestAnimationFrame(animate);
|
||||
};
|
||||
animate();
|
||||
return () => { if (rafRef.current) cancelAnimationFrame(rafRef.current); };
|
||||
}, [draw]);
|
||||
|
||||
// Playback
|
||||
useEffect(() => {
|
||||
if (!state.isRunning) {
|
||||
if (partRef.current) { partRef.current.stop(); partRef.current.dispose(); partRef.current = null; }
|
||||
setPlayPos(-1);
|
||||
return;
|
||||
}
|
||||
|
||||
Tone.getTransport().bpm.value = bpm;
|
||||
|
||||
// Build Tone.Part from notes
|
||||
const events = notesRef.current.map(n => ({
|
||||
time: `0:0:${n.start * 4}`, // convert beats to 16th notes for Tone
|
||||
note: n.note,
|
||||
dur: n.duration,
|
||||
}));
|
||||
|
||||
// Use a simpler approach: schedule directly
|
||||
const part = new Tone.Part((time, ev) => {
|
||||
setSequencerSignals(moduleId, midiToFreq(ev.note), true);
|
||||
Tone.getTransport().scheduleOnce(() => {
|
||||
setSequencerSignals(moduleId, midiToFreq(ev.note), false);
|
||||
}, time + Tone.Time(`0:0:${ev.dur * 4}`).toSeconds() * 0.9);
|
||||
}, events.map(ev => [Tone.Time(ev.time).toSeconds(), { note: ev.note, dur: ev.dur }]));
|
||||
|
||||
part.loop = loop;
|
||||
part.loopEnd = `${bars}m`;
|
||||
part.start(0);
|
||||
|
||||
if (Tone.getTransport().state !== 'started') {
|
||||
Tone.getTransport().start();
|
||||
}
|
||||
partRef.current = part;
|
||||
|
||||
// Track playhead position
|
||||
const posInterval = setInterval(() => {
|
||||
if (Tone.getTransport().state === 'started') {
|
||||
const pos = Tone.getTransport().seconds;
|
||||
const beatDuration = 60 / bpm;
|
||||
const currentBeat = (pos / beatDuration) % totalBeats;
|
||||
setPlayPos(currentBeat);
|
||||
}
|
||||
}, 30);
|
||||
|
||||
return () => {
|
||||
clearInterval(posInterval);
|
||||
if (partRef.current) { partRef.current.stop(); partRef.current.dispose(); partRef.current = null; }
|
||||
};
|
||||
}, [state.isRunning, moduleId, bpm, bars, loop]);
|
||||
|
||||
// Mouse interaction for drawing/erasing notes
|
||||
const handleMouseDown = useCallback((e) => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const mx = e.clientX - rect.left;
|
||||
const my = e.clientY - rect.top;
|
||||
|
||||
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;
|
||||
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]);
|
||||
|
||||
return (
|
||||
<div style={{ width: ROLL_W }}>
|
||||
{/* 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>
|
||||
<span style={{ fontSize: 8, color: '#555', marginLeft: 'auto', alignSelf: 'center' }}>
|
||||
{notes.length} notes · {bars} bars
|
||||
</span>
|
||||
</div>
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
width={ROLL_W}
|
||||
height={ROLL_H}
|
||||
style={{ width: ROLL_W, height: ROLL_H, borderRadius: 4, cursor: tool === 'draw' ? 'crosshair' : 'pointer' }}
|
||||
onPointerDown={handleMouseDown}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
163
src/components/SequencerWidget.jsx
Normal file
163
src/components/SequencerWidget.jsx
Normal file
@@ -0,0 +1,163 @@
|
||||
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import * as Tone from 'tone';
|
||||
import { state, updateModuleParam, emit } from '../engine/state.js';
|
||||
import { setSequencerSignals, getAudioNode } 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); }
|
||||
|
||||
// Default notes: C minor pentatonic pattern
|
||||
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 [currentStep, setCurrentStep] = useState(-1);
|
||||
const seqRef = useRef(null);
|
||||
const stepsRef = useRef(null);
|
||||
|
||||
// Init steps data
|
||||
const numSteps = parseInt(mod?.params?.steps || '16');
|
||||
if (!mod?.params?._steps) {
|
||||
const initial = DEFAULT_STEPS.slice(0, numSteps);
|
||||
while (initial.length < numSteps) initial.push({ midi: 60, gate: false });
|
||||
if (mod) {
|
||||
mod.params._steps = initial;
|
||||
}
|
||||
}
|
||||
const steps = mod?.params?._steps || DEFAULT_STEPS;
|
||||
stepsRef.current = steps;
|
||||
|
||||
const bpm = mod?.params?.bpm ?? 140;
|
||||
|
||||
// Start/stop sequencer when audio engine runs
|
||||
useEffect(() => {
|
||||
if (!state.isRunning) {
|
||||
if (seqRef.current) { seqRef.current.stop(); seqRef.current.dispose(); seqRef.current = null; }
|
||||
setCurrentStep(-1);
|
||||
return;
|
||||
}
|
||||
|
||||
Tone.getTransport().bpm.value = bpm;
|
||||
|
||||
const seq = new Tone.Sequence((time, stepIdx) => {
|
||||
const s = stepsRef.current[stepIdx];
|
||||
if (!s) return;
|
||||
setCurrentStep(stepIdx);
|
||||
|
||||
if (s.gate) {
|
||||
setSequencerSignals(moduleId, midiToFreq(s.midi), true);
|
||||
Tone.getTransport().scheduleOnce(() => {
|
||||
setSequencerSignals(moduleId, midiToFreq(s.midi), false);
|
||||
}, time + Tone.Time('16n').toSeconds() * 0.8);
|
||||
} else {
|
||||
setSequencerSignals(moduleId, midiToFreq(s.midi), false);
|
||||
}
|
||||
}, Array.from({ length: numSteps }, (_, i) => i), '16n');
|
||||
|
||||
seq.start(0);
|
||||
if (Tone.getTransport().state !== 'started') {
|
||||
Tone.getTransport().start();
|
||||
}
|
||||
seqRef.current = seq;
|
||||
|
||||
return () => {
|
||||
if (seqRef.current) { seqRef.current.stop(); seqRef.current.dispose(); seqRef.current = null; }
|
||||
};
|
||||
}, [state.isRunning, moduleId, numSteps]);
|
||||
|
||||
// Update BPM live
|
||||
useEffect(() => {
|
||||
if (state.isRunning) Tone.getTransport().bpm.value = bpm;
|
||||
}, [bpm]);
|
||||
|
||||
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 */}
|
||||
{steps.slice(0, numSteps).map((s, i) => {
|
||||
const x = i * CELL_W;
|
||||
const isActive = i === currentStep;
|
||||
const barHeight = ((s.midi - 36) / 60) * (CELL_H - 4);
|
||||
|
||||
return (
|
||||
<g key={i}>
|
||||
{/* Background */}
|
||||
<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}
|
||||
/>
|
||||
{/* Note bar */}
|
||||
{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}
|
||||
/>
|
||||
)}
|
||||
{/* Inactive marker */}
|
||||
{!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} />
|
||||
)}
|
||||
{/* Note name */}
|
||||
<text x={x + CELL_W / 2} y={CELL_H + 10} textAnchor="middle"
|
||||
fontSize={6} fill={s.gate ? '#88ccff' : '#333'} fontFamily="monospace">
|
||||
{noteLabel(s.midi)}
|
||||
</text>
|
||||
{/* Click areas: top half = note up, bottom half = note down, middle = toggle gate */}
|
||||
<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>
|
||||
);
|
||||
})}
|
||||
{/* Playhead line */}
|
||||
{currentStep >= 0 && (
|
||||
<line
|
||||
x1={currentStep * CELL_W + CELL_W / 2} y1={0}
|
||||
x2={currentStep * 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>
|
||||
);
|
||||
}
|
||||
@@ -146,7 +146,6 @@ function createNode(mod) {
|
||||
};
|
||||
}
|
||||
case 'keyboard': {
|
||||
// Keyboard outputs frequency as a Signal and gate as a Signal
|
||||
const freqSig = new Tone.Signal(440);
|
||||
const gateSig = new Tone.Signal(0);
|
||||
return {
|
||||
@@ -158,6 +157,37 @@ function createNode(mod) {
|
||||
dispose: () => { freqSig.dispose(); gateSig.dispose(); },
|
||||
};
|
||||
}
|
||||
case 'sequencer': {
|
||||
const freqSig = new Tone.Signal(440);
|
||||
const gateSig = new Tone.Signal(0);
|
||||
// Sequencer loop managed externally by SequencerWidget
|
||||
return {
|
||||
node: null,
|
||||
inputs: {},
|
||||
outputs: { freq: freqSig, gate: gateSig },
|
||||
_freqSig: freqSig,
|
||||
_gateSig: gateSig,
|
||||
_seq: null, // Tone.Sequence set by widget
|
||||
dispose: () => {
|
||||
freqSig.dispose(); gateSig.dispose();
|
||||
},
|
||||
};
|
||||
}
|
||||
case 'pianoroll': {
|
||||
const freqSig = new Tone.Signal(440);
|
||||
const gateSig = new Tone.Signal(0);
|
||||
return {
|
||||
node: null,
|
||||
inputs: {},
|
||||
outputs: { freq: freqSig, gate: gateSig },
|
||||
_freqSig: freqSig,
|
||||
_gateSig: gateSig,
|
||||
_part: null, // Tone.Part set by widget
|
||||
dispose: () => {
|
||||
freqSig.dispose(); gateSig.dispose();
|
||||
},
|
||||
};
|
||||
}
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
@@ -280,11 +310,31 @@ export function updateParam(moduleId, paramName, value) {
|
||||
if (paramName === 'volume') entry.node.gain.value = Tone.dbToGain(value);
|
||||
break;
|
||||
case 'keyboard':
|
||||
if (paramName === 'octave') { /* stored in state only */ }
|
||||
case 'sequencer':
|
||||
case 'pianoroll':
|
||||
// All params stored in state, managed by widgets
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
export function setSequencerSignals(moduleId, freq, gate) {
|
||||
const entry = audioNodes[moduleId];
|
||||
if (!entry) return;
|
||||
if (entry._freqSig) entry._freqSig.value = freq;
|
||||
if (entry._gateSig) entry._gateSig.value = gate ? 1 : 0;
|
||||
|
||||
// Trigger connected envelopes
|
||||
for (const conn of state.connections) {
|
||||
if (conn.from.moduleId === moduleId && conn.from.port === 'gate') {
|
||||
const envEntry = audioNodes[conn.to.moduleId];
|
||||
if (envEntry && envEntry.node instanceof Tone.Envelope) {
|
||||
if (gate) envEntry.node.triggerAttack();
|
||||
else envEntry.node.triggerRelease();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function triggerKeyboard(moduleId, freq, gate) {
|
||||
const entry = audioNodes[moduleId];
|
||||
if (!entry) return;
|
||||
|
||||
@@ -257,3 +257,41 @@ defineModule('keyboard', {
|
||||
octave: { type: 'knob', min: 1, max: 8, default: 4, unit: '', label: 'Octave' },
|
||||
},
|
||||
});
|
||||
|
||||
// ==================== SEQUENCER ====================
|
||||
|
||||
defineModule('sequencer', {
|
||||
name: 'Sequencer',
|
||||
icon: '▦',
|
||||
category: 'Source',
|
||||
inputs: [],
|
||||
outputs: [
|
||||
{ name: 'freq', type: PORT_TYPE.AUDIO, label: 'Freq' },
|
||||
{ name: 'gate', type: PORT_TYPE.TRIGGER, label: 'Gate' },
|
||||
],
|
||||
params: {
|
||||
bpm: { type: 'knob', min: 40, max: 300, default: 140, unit: 'bpm', label: 'BPM' },
|
||||
steps: { type: 'select', options: ['8', '16', '32'], default: '16', label: 'Steps' },
|
||||
swing: { type: 'knob', min: 0, max: 0.5, default: 0, unit: '', label: 'Swing' },
|
||||
},
|
||||
// Custom data: step notes/gates stored in module.params._steps
|
||||
});
|
||||
|
||||
// ==================== PIANO ROLL ====================
|
||||
|
||||
defineModule('pianoroll', {
|
||||
name: 'Piano Roll',
|
||||
icon: '🎼',
|
||||
category: 'Source',
|
||||
inputs: [],
|
||||
outputs: [
|
||||
{ name: 'freq', type: PORT_TYPE.AUDIO, label: 'Freq' },
|
||||
{ name: 'gate', type: PORT_TYPE.TRIGGER, label: 'Gate' },
|
||||
],
|
||||
params: {
|
||||
bpm: { type: 'knob', min: 40, max: 300, default: 140, unit: 'bpm', label: 'BPM' },
|
||||
loop: { type: 'select', options: ['on', 'off'], default: 'on', label: 'Loop' },
|
||||
bars: { type: 'select', options: ['1', '2', '4', '8'], default: '4', label: 'Bars' },
|
||||
},
|
||||
// Custom data: notes stored in module.params._notes = [{note, start, duration}, ...]
|
||||
});
|
||||
|
||||
@@ -89,7 +89,7 @@ html, body, #root {
|
||||
|
||||
/* ===== Modules ===== */
|
||||
.module {
|
||||
position: absolute; width: 180px;
|
||||
position: absolute; width: 180px; min-width: 180px;
|
||||
background: var(--surface); border: 1px solid var(--border);
|
||||
border-radius: 8px; user-select: none; z-index: 2;
|
||||
box-shadow: 0 4px 16px rgba(0,0,0,0.4);
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
* Chiptune Demo Preset
|
||||
*
|
||||
* Signal flow:
|
||||
* Keyboard → [freq] Osc1 (square, melody) → VCA1 ← Envelope1 (short plucky)
|
||||
* Keyboard → [freq] Osc2 (square, -12 oct sub bass) → VCA2 ← Envelope2 (bass sustain)
|
||||
* Piano Roll → [freq] Osc1 (square, melody) → VCA1 ← Envelope1 (short plucky)
|
||||
* Piano Roll → [freq] Osc2 (square, -12 oct sub bass) → VCA2 ← Envelope2 (bass sustain)
|
||||
* LFO (vibrato) → Osc1 detune
|
||||
* VCA1 + VCA2 → Mixer → Filter (lowpass, slight resonance) → Delay → Distortion (light) → Output
|
||||
* Mixer → Scope (visualization)
|
||||
@@ -13,34 +13,34 @@
|
||||
|
||||
export const CHIPTUNE_PRESET = {
|
||||
modules: [
|
||||
// Row 1: Keyboard & Sources
|
||||
{ id: 1, type: 'keyboard', x: 40, y: 40, params: { octave: 4 } },
|
||||
{ id: 2, type: 'oscillator', x: 280, y: 20, params: { waveform: 'square', frequency: 440, detune: 0 } },
|
||||
{ id: 3, type: 'oscillator', x: 280, y: 260, params: { waveform: 'square', frequency: 220, detune: 0 } },
|
||||
{ id: 4, type: 'lfo', x: 40, y: 280, params: { waveform: 'sine', frequency: 5.5, amplitude: 0.3 } },
|
||||
// Row 1: Piano Roll & Sources
|
||||
{ id: 1, type: 'pianoroll', x: 40, y: 40, params: { bpm: 140, loop: 'on', bars: '4' } },
|
||||
{ id: 2, type: 'oscillator', x: 600, y: 20, params: { waveform: 'square', frequency: 440, detune: 0 } },
|
||||
{ id: 3, type: 'oscillator', x: 600, y: 260, params: { waveform: 'square', frequency: 220, detune: 0 } },
|
||||
{ id: 4, type: 'lfo', x: 40, y: 380, params: { waveform: 'sine', frequency: 5.5, amplitude: 0.3 } },
|
||||
|
||||
// Row 2: Envelopes & VCAs
|
||||
{ id: 5, type: 'envelope', x: 500, y: 20, params: { attack: 0.005, decay: 0.15, sustain: 0.2, release: 0.1 } },
|
||||
{ id: 6, type: 'envelope', x: 500, y: 240, params: { attack: 0.005, decay: 0.3, sustain: 0.6, release: 0.2 } },
|
||||
{ id: 7, type: 'vca', x: 700, y: 20, params: { gain: 0.7 } },
|
||||
{ id: 8, type: 'vca', x: 700, y: 220, params: { gain: 0.5 } },
|
||||
{ id: 5, type: 'envelope', x: 820, y: 20, params: { attack: 0.005, decay: 0.15, sustain: 0.2, release: 0.1 } },
|
||||
{ id: 6, type: 'envelope', x: 820, y: 240, params: { attack: 0.005, decay: 0.3, sustain: 0.6, release: 0.2 } },
|
||||
{ id: 7, type: 'vca', x: 1020, y: 20, params: { gain: 0.7 } },
|
||||
{ id: 8, type: 'vca', x: 1020, y: 220, params: { gain: 0.5 } },
|
||||
|
||||
// Row 3: Mixer, processing, output
|
||||
{ id: 9, type: 'mixer', x: 900, y: 60, params: { gain1: 0.8, gain2: 0.6, gain3: 0.0, gain4: 0.0 } },
|
||||
{ id: 10, type: 'filter', x: 1100, y: 40, params: { type: 'lowpass', frequency: 3500, Q: 2.5 } },
|
||||
{ id: 11, type: 'delay', x: 1300, y: 40, params: { delayTime: 0.18, feedback: 0.35, wet: 0.25 } },
|
||||
{ id: 12, type: 'distortion', x: 1300, y: 280, params: { distortion: 0.15, wet: 0.3 } },
|
||||
{ id: 13, type: 'output', x: 1520, y: 120, params: { volume: -8 } },
|
||||
{ id: 9, type: 'mixer', x: 1220, y: 60, params: { gain1: 0.8, gain2: 0.6, gain3: 0.0, gain4: 0.0 } },
|
||||
{ id: 10, type: 'filter', x: 1420, y: 40, params: { type: 'lowpass', frequency: 3500, Q: 2.5 } },
|
||||
{ id: 11, type: 'delay', x: 1620, y: 40, params: { delayTime: 0.18, feedback: 0.35, wet: 0.25 } },
|
||||
{ id: 12, type: 'distortion', x: 1620, y: 280, params: { distortion: 0.15, wet: 0.3 } },
|
||||
{ id: 13, type: 'output', x: 1840, y: 120, params: { volume: -8 } },
|
||||
|
||||
// Scope
|
||||
{ id: 14, type: 'scope', x: 900, y: 320, params: {} },
|
||||
{ id: 14, type: 'scope', x: 1220, y: 320, params: {} },
|
||||
],
|
||||
connections: [
|
||||
// Keyboard → Oscillators (freq)
|
||||
// Piano Roll → Oscillators (freq)
|
||||
{ id: 1, from: { moduleId: 1, port: 'freq' }, to: { moduleId: 2, port: 'freq' } },
|
||||
{ id: 2, from: { moduleId: 1, port: 'freq' }, to: { moduleId: 3, port: 'freq' } },
|
||||
|
||||
// Keyboard → Envelopes (gate)
|
||||
// Piano Roll → Envelopes (gate)
|
||||
{ id: 3, from: { moduleId: 1, port: 'gate' }, to: { moduleId: 5, port: 'gate' } },
|
||||
{ id: 4, from: { moduleId: 1, port: 'gate' }, to: { moduleId: 6, port: 'gate' } },
|
||||
|
||||
|
||||
Reference in New Issue
Block a user