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:
Jose Luis
2026-03-21 01:30:03 +01:00
parent 4a003f2af2
commit 65a89e2b59
7 changed files with 647 additions and 23 deletions

View File

@@ -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">

View 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>
);
}

View 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>
);
}

View File

@@ -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;

View File

@@ -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}, ...]
});

View File

@@ -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);

View File

@@ -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' } },