diff --git a/src/components/ModuleNode.jsx b/src/components/ModuleNode.jsx index 23e123f..fd0d496 100644 --- a/src/components/ModuleNode.jsx +++ b/src/components/ModuleNode.jsx @@ -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 (
{ // 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' && } + {/* Sequencer widget */} + {mod.type === 'sequencer' && } + + {/* Piano Roll widget */} + {mod.type === 'pianoroll' && } + {/* Output ports */} {def.outputs.map(port => (
diff --git a/src/components/PianoRollWidget.jsx b/src/components/PianoRollWidget.jsx new file mode 100644 index 0000000..f31a5eb --- /dev/null +++ b/src/components/PianoRollWidget.jsx @@ -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 ( +
+ {/* Mini toolbar */} +
+ + + + {notes.length} notes · {bars} bars + +
+ +
+ ); +} diff --git a/src/components/SequencerWidget.jsx b/src/components/SequencerWidget.jsx new file mode 100644 index 0000000..61b3750 --- /dev/null +++ b/src/components/SequencerWidget.jsx @@ -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 ( +
+ + {/* 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 ( + + {/* Background */} + + {/* Note bar */} + {s.gate && ( + + )} + {/* Inactive marker */} + {!s.gate && ( + + )} + {/* Note name */} + + {noteLabel(s.midi)} + + {/* Click areas: top half = note up, bottom half = note down, middle = toggle gate */} + changeNote(i, 1)} + /> + toggleGate(i)} + /> + changeNote(i, -1)} + /> + + ); + })} + {/* Playhead line */} + {currentStep >= 0 && ( + + )} + +
+ ↑top/↓bot: pitch · mid: toggle +
+
+ ); +} diff --git a/src/engine/audioEngine.js b/src/engine/audioEngine.js index d39dd3e..832f241 100644 --- a/src/engine/audioEngine.js +++ b/src/engine/audioEngine.js @@ -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; diff --git a/src/engine/moduleRegistry.js b/src/engine/moduleRegistry.js index 715d251..02ebb7d 100644 --- a/src/engine/moduleRegistry.js +++ b/src/engine/moduleRegistry.js @@ -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}, ...] +}); diff --git a/src/index.css b/src/index.css index 990e539..e45dbc6 100644 --- a/src/index.css +++ b/src/index.css @@ -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); diff --git a/src/presets/chiptune.js b/src/presets/chiptune.js index 4e9128a..dd19c4b 100644 --- a/src/presets/chiptune.js +++ b/src/presets/chiptune.js @@ -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' } },