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 Knob from './Knob.jsx';
|
||||||
import ScopeDisplay from './ScopeDisplay.jsx';
|
import ScopeDisplay from './ScopeDisplay.jsx';
|
||||||
import KeyboardWidget from './KeyboardWidget.jsx';
|
import KeyboardWidget from './KeyboardWidget.jsx';
|
||||||
|
import SequencerWidget from './SequencerWidget.jsx';
|
||||||
|
import PianoRollWidget from './PianoRollWidget.jsx';
|
||||||
|
|
||||||
export default function ModuleNode({ mod, zoom, onStartConnect, onPortPosition }) {
|
export default function ModuleNode({ mod, zoom, onStartConnect, onPortPosition }) {
|
||||||
const def = getModuleDef(mod.type);
|
const def = getModuleDef(mod.type);
|
||||||
@@ -65,7 +67,11 @@ export default function ModuleNode({ mod, zoom, onStartConnect, onPortPosition }
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`module ${isSelected ? 'selected' : ''}`}
|
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}
|
data-module-id={mod.id}
|
||||||
onPointerDown={(e) => {
|
onPointerDown={(e) => {
|
||||||
// Don't deselect when clicking inside a module
|
// Don't deselect when clicking inside a module
|
||||||
@@ -142,6 +148,12 @@ export default function ModuleNode({ mod, zoom, onStartConnect, onPortPosition }
|
|||||||
{/* Keyboard widget */}
|
{/* Keyboard widget */}
|
||||||
{mod.type === 'keyboard' && <KeyboardWidget moduleId={mod.id} />}
|
{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 */}
|
{/* Output ports */}
|
||||||
{def.outputs.map(port => (
|
{def.outputs.map(port => (
|
||||||
<div key={port.name} className="port-row output">
|
<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': {
|
case 'keyboard': {
|
||||||
// Keyboard outputs frequency as a Signal and gate as a Signal
|
|
||||||
const freqSig = new Tone.Signal(440);
|
const freqSig = new Tone.Signal(440);
|
||||||
const gateSig = new Tone.Signal(0);
|
const gateSig = new Tone.Signal(0);
|
||||||
return {
|
return {
|
||||||
@@ -158,6 +157,37 @@ function createNode(mod) {
|
|||||||
dispose: () => { freqSig.dispose(); gateSig.dispose(); },
|
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:
|
default:
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -280,11 +310,31 @@ export function updateParam(moduleId, paramName, value) {
|
|||||||
if (paramName === 'volume') entry.node.gain.value = Tone.dbToGain(value);
|
if (paramName === 'volume') entry.node.gain.value = Tone.dbToGain(value);
|
||||||
break;
|
break;
|
||||||
case 'keyboard':
|
case 'keyboard':
|
||||||
if (paramName === 'octave') { /* stored in state only */ }
|
case 'sequencer':
|
||||||
|
case 'pianoroll':
|
||||||
|
// All params stored in state, managed by widgets
|
||||||
break;
|
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) {
|
export function triggerKeyboard(moduleId, freq, gate) {
|
||||||
const entry = audioNodes[moduleId];
|
const entry = audioNodes[moduleId];
|
||||||
if (!entry) return;
|
if (!entry) return;
|
||||||
|
|||||||
@@ -257,3 +257,41 @@ defineModule('keyboard', {
|
|||||||
octave: { type: 'knob', min: 1, max: 8, default: 4, unit: '', label: 'Octave' },
|
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 ===== */
|
/* ===== Modules ===== */
|
||||||
.module {
|
.module {
|
||||||
position: absolute; width: 180px;
|
position: absolute; width: 180px; min-width: 180px;
|
||||||
background: var(--surface); border: 1px solid var(--border);
|
background: var(--surface); border: 1px solid var(--border);
|
||||||
border-radius: 8px; user-select: none; z-index: 2;
|
border-radius: 8px; user-select: none; z-index: 2;
|
||||||
box-shadow: 0 4px 16px rgba(0,0,0,0.4);
|
box-shadow: 0 4px 16px rgba(0,0,0,0.4);
|
||||||
|
|||||||
@@ -2,8 +2,8 @@
|
|||||||
* Chiptune Demo Preset
|
* Chiptune Demo Preset
|
||||||
*
|
*
|
||||||
* Signal flow:
|
* Signal flow:
|
||||||
* Keyboard → [freq] Osc1 (square, melody) → VCA1 ← Envelope1 (short plucky)
|
* Piano Roll → [freq] Osc1 (square, melody) → VCA1 ← Envelope1 (short plucky)
|
||||||
* Keyboard → [freq] Osc2 (square, -12 oct sub bass) → VCA2 ← Envelope2 (bass sustain)
|
* Piano Roll → [freq] Osc2 (square, -12 oct sub bass) → VCA2 ← Envelope2 (bass sustain)
|
||||||
* LFO (vibrato) → Osc1 detune
|
* LFO (vibrato) → Osc1 detune
|
||||||
* VCA1 + VCA2 → Mixer → Filter (lowpass, slight resonance) → Delay → Distortion (light) → Output
|
* VCA1 + VCA2 → Mixer → Filter (lowpass, slight resonance) → Delay → Distortion (light) → Output
|
||||||
* Mixer → Scope (visualization)
|
* Mixer → Scope (visualization)
|
||||||
@@ -13,34 +13,34 @@
|
|||||||
|
|
||||||
export const CHIPTUNE_PRESET = {
|
export const CHIPTUNE_PRESET = {
|
||||||
modules: [
|
modules: [
|
||||||
// Row 1: Keyboard & Sources
|
// Row 1: Piano Roll & Sources
|
||||||
{ id: 1, type: 'keyboard', x: 40, y: 40, params: { octave: 4 } },
|
{ id: 1, type: 'pianoroll', x: 40, y: 40, params: { bpm: 140, loop: 'on', bars: '4' } },
|
||||||
{ id: 2, type: 'oscillator', x: 280, y: 20, params: { waveform: 'square', frequency: 440, detune: 0 } },
|
{ id: 2, type: 'oscillator', x: 600, 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: 3, type: 'oscillator', x: 600, 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 } },
|
{ id: 4, type: 'lfo', x: 40, y: 380, params: { waveform: 'sine', frequency: 5.5, amplitude: 0.3 } },
|
||||||
|
|
||||||
// Row 2: Envelopes & VCAs
|
// 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: 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: 500, y: 240, params: { attack: 0.005, decay: 0.3, sustain: 0.6, release: 0.2 } },
|
{ 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: 700, y: 20, params: { gain: 0.7 } },
|
{ id: 7, type: 'vca', x: 1020, y: 20, params: { gain: 0.7 } },
|
||||||
{ id: 8, type: 'vca', x: 700, y: 220, params: { gain: 0.5 } },
|
{ id: 8, type: 'vca', x: 1020, y: 220, params: { gain: 0.5 } },
|
||||||
|
|
||||||
// Row 3: Mixer, processing, output
|
// 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: 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: 1100, y: 40, params: { type: 'lowpass', frequency: 3500, Q: 2.5 } },
|
{ id: 10, type: 'filter', x: 1420, 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: 11, type: 'delay', x: 1620, 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: 12, type: 'distortion', x: 1620, y: 280, params: { distortion: 0.15, wet: 0.3 } },
|
||||||
{ id: 13, type: 'output', x: 1520, y: 120, params: { volume: -8 } },
|
{ id: 13, type: 'output', x: 1840, y: 120, params: { volume: -8 } },
|
||||||
|
|
||||||
// Scope
|
// Scope
|
||||||
{ id: 14, type: 'scope', x: 900, y: 320, params: {} },
|
{ id: 14, type: 'scope', x: 1220, y: 320, params: {} },
|
||||||
],
|
],
|
||||||
connections: [
|
connections: [
|
||||||
// Keyboard → Oscillators (freq)
|
// Piano Roll → Oscillators (freq)
|
||||||
{ id: 1, from: { moduleId: 1, port: 'freq' }, to: { moduleId: 2, port: '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' } },
|
{ 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: 3, from: { moduleId: 1, port: 'gate' }, to: { moduleId: 5, port: 'gate' } },
|
||||||
{ id: 4, from: { moduleId: 1, port: 'gate' }, to: { moduleId: 6, port: 'gate' } },
|
{ id: 4, from: { moduleId: 1, port: 'gate' }, to: { moduleId: 6, port: 'gate' } },
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user