#3878 Synthesizer App
#3878 Synthesizer App
#3878 Let's build a music synthesizer step-by-step
This is an exciting project! Building a synthesizer is the perfect way to understand how computers generate audio.
For Step 1, we are going to build the Core Sound Engine.
We won't worry about complex effects or sequencers yet. Our goal right now is to achieve:
Polyphony: The ability to play multiple notes at once.
Oscillators: Generating the raw sound (Sine, Square, Sawtooth, Triangle).
Envelope (ADSR): Controlling how the sound starts and stops (Attack and Release) to prevent "popping" noises.
Visualization: Seeing the sound wave in real-time.
I'll write this in React. It will handle keyboard input (your computer keyboard) and mouse input.
import React, { useState, useEffect, useRef } from 'react';
import { Volume2, Activity, Zap, Settings, Music } from 'lucide-react';
// Frequency data for the 4th octave (Middle C)
// We can calculate other octaves mathmatically, but this is a good base.
const NOTES = [
{ note: 'C', freq: 261.63, key: 'a', type: 'white' },
{ note: 'C#', freq: 277.18, key: 'w', type: 'black' },
{ note: 'D', freq: 293.66, key: 's', type: 'white' },
{ note: 'D#', freq: 311.13, key: 'e', type: 'black' },
{ note: 'E', freq: 329.63, key: 'd', type: 'white' },
{ note: 'F', freq: 349.23, key: 'f', type: 'white' },
{ note: 'F#', freq: 369.99, key: 't', type: 'black' },
{ note: 'G', freq: 392.00, key: 'g', type: 'white' },
{ note: 'G#', freq: 415.30, key: 'y', type: 'black' },
{ note: 'A', freq: 440.00, key: 'h', type: 'white' },
{ note: 'A#', freq: 466.16, key: 'u', type: 'black' },
{ note: 'B', freq: 493.88, key: 'j', type: 'white' },
{ note: 'C2', freq: 523.25, key: 'k', type: 'white' },
];
const App = () => {
// --- Audio Context State ---
const audioCtxRef = useRef(null);
const masterGainRef = useRef(null);
const analyzerRef = useRef(null);
const activeOscillators = useRef(new Map()); // Keeps track of currently playing notes
// --- UI State ---
const [isInitialized, setIsInitialized] = useState(false);
const [waveform, setWaveform] = useState('sawtooth');
const [volume, setVolume] = useState(0.5);
const [attack, setAttack] = useState(0.01);
const [release, setRelease] = useState(0.5);
const [activeKeys, setActiveKeys] = useState(new Set());
// --- Visualizer Ref ---
const canvasRef = useRef(null);
const animationRef = useRef(null);
// Initialize Audio Engine
const initAudio = () => {
if (audioCtxRef.current) return;
const AudioContext = window.AudioContext || window.webkitAudioContext;
const ctx = new AudioContext();
// Master Volume
const mainGain = ctx.createGain();
mainGain.gain.value = volume;
// Analyzer (Visualizer)
const analyzer = ctx.createAnalyser();
analyzer.fftSize = 2048;
// Connections: Master -> Analyzer -> Speakers
mainGain.connect(analyzer);
analyzer.connect(ctx.destination);
audioCtxRef.current = ctx;
masterGainRef.current = mainGain;
analyzerRef.current = analyzer;
setIsInitialized(true);
drawVisualizer();
};
// Update master volume when state changes
useEffect(() => {
if (masterGainRef.current) {
masterGainRef.current.gain.setTargetAtTime(volume, audioCtxRef.current.currentTime, 0.1);
}
}, [volume]);
// --- Sound Generation Logic ---
const playNote = (freq) => {
if (!audioCtxRef.current) initAudio();
if (audioCtxRef.current.state === 'suspended') audioCtxRef.current.resume();
// If note is already playing, ignore (prevents phasing on key hold)
if (activeOscillators.current.has(freq)) return;
const ctx = audioCtxRef.current;
const now = ctx.currentTime;
// 1. Create Oscillator
const osc = ctx.createOscillator();
osc.type = waveform;
osc.frequency.setValueAtTime(freq, now);
// 2. Create Note Gain (Envelope)
const noteGain = ctx.createGain();
noteGain.gain.setValueAtTime(0, now);
// Attack Phase: Ramp up to 1
noteGain.gain.linearRampToValueAtTime(1, now + parseFloat(attack));
// 3. Connect: Osc -> NoteGain -> MasterGain
osc.connect(noteGain);
noteGain.connect(masterGainRef.current);
osc.start(now);
// Store reference to stop it later
activeOscillators.current.set(freq, { osc, noteGain });
};
const stopNote = (freq) => {
const activeNote = activeOscillators.current.get(freq);
if (activeNote && audioCtxRef.current) {
const { osc, noteGain } = activeNote;
const now = audioCtxRef.current.currentTime;
// Release Phase: Ramp down to 0
// We cancel scheduled values just in case the attack hasn't finished
noteGain.gain.cancelScheduledValues(now);
noteGain.gain.setValueAtTime(noteGain.gain.value, now);
noteGain.gain.exponentialRampToValueAtTime(0.001, now + parseFloat(release));
// Stop oscillator after release
osc.stop(now + parseFloat(release) + 0.1);
// Cleanup map
setTimeout(() => {
// Small delay to ensure garbage collection doesn't happen during sound
// In a complex app we might do this differently
}, parseFloat(release) * 1000);
activeOscillators.current.delete(freq);
}
};
// --- Input Handling ---
const handleKeyDown = (e) => {
if (e.repeat) return;
const noteData = NOTES.find(n => n.key === e.key.toLowerCase());
if (noteData) {
setActiveKeys(prev => new Set(prev).add(noteData.note));
playNote(noteData.freq);
}
};
const handleKeyUp = (e) => {
const noteData = NOTES.find(n => n.key === e.key.toLowerCase());
if (noteData) {
setActiveKeys(prev => {
const next = new Set(prev);
next.delete(noteData.note);
return next;
});
stopNote(noteData.freq);
}
};
const handleMouseDown = (freq, note) => {
setActiveKeys(prev => new Set(prev).add(note));
playNote(freq);
};
const handleMouseUp = (freq, note) => {
setActiveKeys(prev => {
const next = new Set(prev);
next.delete(note);
return next;
});
stopNote(freq);
};
useEffect(() => {
window.addEventListener('keydown', handleKeyDown);
window.addEventListener('keyup', handleKeyUp);
return () => {
window.removeEventListener('keydown', handleKeyDown);
window.removeEventListener('keyup', handleKeyUp);
};
}, [waveform, attack, release]); // Re-bind if synthesis params change contextually
// --- Visualization Loop ---
const drawVisualizer = () => {
if (!canvasRef.current || !analyzerRef.current) return;
const canvas = canvasRef.current;
const canvasCtx = canvas.getContext('2d');
const analyzer = analyzerRef.current;
const bufferLength = analyzer.frequencyBinCount;
const dataArray = new Uint8Array(bufferLength);
const draw = () => {
animationRef.current = requestAnimationFrame(draw);
analyzer.getByteTimeDomainData(dataArray);
canvasCtx.fillStyle = '#111827'; // match bg-gray-900
canvasCtx.fillRect(0, 0, canvas.width, canvas.height);
canvasCtx.lineWidth = 2;
canvasCtx.strokeStyle = '#4ade80'; // green-400
canvasCtx.beginPath();
const sliceWidth = canvas.width * 1.0 / bufferLength;
let x = 0;
for (let i = 0; i < bufferLength; i++) {
const v = dataArray[i] / 128.0;
const y = v * canvas.height / 2;
if (i === 0) {
canvasCtx.moveTo(x, y);
} else {
canvasCtx.lineTo(x, y);
}
x += sliceWidth;
}
canvasCtx.lineTo(canvas.width, canvas.height / 2);
canvasCtx.stroke();
};
draw();
};
return (
<div className="min-h-screen bg-gray-900 text-gray-100 font-sans selection:bg-green-500 selection:text-black p-4 sm:p-8 flex flex-col items-center justify-center">
{/* Header */}
<div className="w-full max-w-4xl mb-8 flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="p-2 bg-green-500 rounded-lg text-gray-900">
<Music size={24} />
</div>
<div>
<h1 className="text-2xl font-bold tracking-wider text-white">NEO-SYNTH <span className="text-green-400 text-sm font-mono">v1.0</span></h1>
<p className="text-gray-400 text-xs uppercase tracking-widest">Polyphonic Web Oscillator</p>
</div>
</div>
{!isInitialized && (
<button
onClick={initAudio}
className="flex items-center gap-2 bg-green-500 hover:bg-green-400 text-gray-900 px-4 py-2 rounded-full font-bold transition-all shadow-[0_0_15px_rgba(74,222,128,0.5)] animate-pulse"
>
<Zap size={18} />
START ENGINE
</button>
)}
</div>
{/* Main Synth Panel */}
<div className="w-full max-w-4xl bg-gray-800 border border-gray-700 rounded-xl shadow-2xl overflow-hidden">
{/* Visualizer Display */}
<div className="relative h-48 bg-black border-b border-gray-700">
<canvas
ref={canvasRef}
width={800}
height={200}
className="w-full h-full opacity-80"
/>
<div className="absolute top-4 right-4 flex items-center gap-2 text-xs text-green-500 font-mono bg-gray-900/80 px-2 py-1 rounded border border-green-500/30">
<Activity size={14} />
OSCILLOSCOPE
</div>
</div>
{/* Controls Area */}
<div className="p-6 grid grid-cols-1 md:grid-cols-3 gap-8 bg-gradient-to-b from-gray-800 to-gray-900">
{/* Section 1: Oscillator */}
<div className="space-y-4">
<div className="flex items-center gap-2 text-green-400 border-b border-gray-700 pb-2 mb-2">
<Settings size={16} />
<h3 className="font-bold text-sm uppercase tracking-wider">Oscillator</h3>
</div>
<div className="space-y-3">
<label className="text-xs text-gray-400 uppercase font-semibold">Waveform</label>
<div className="grid grid-cols-2 gap-2">
{['sawtooth', 'square', 'triangle', 'sine'].map(type => (
<button
key={type}
onClick={() => setWaveform(type)}
className={`px-3 py-2 text-xs font-bold uppercase rounded border transition-all ${
waveform === type
? 'bg-green-500 text-gray-900 border-green-500 shadow-[0_0_10px_rgba(74,222,128,0.3)]'
: 'bg-gray-700 text-gray-300 border-gray-600 hover:bg-gray-600'
}`}
>
{type}
</button>
))}
</div>
</div>
</div>
{/* Section 2: Envelope (ADSR) */}
<div className="space-y-4">
<div className="flex items-center gap-2 text-blue-400 border-b border-gray-700 pb-2 mb-2">
<Activity size={16} />
<h3 className="font-bold text-sm uppercase tracking-wider">Envelope</h3>
</div>
<div className="space-y-4">
<div>
<div className="flex justify-between text-xs mb-1">
<span className="text-gray-400">Attack</span>
<span className="text-blue-400 font-mono">{attack}s</span>
</div>
<input
type="range"
min="0.001"
max="1"
step="0.01"
value={attack}
onChange={(e) => setAttack(e.target.value)}
className="w-full h-2 bg-gray-700 rounded-lg appearance-none cursor-pointer accent-blue-500"
/>
</div>
<div>
<div className="flex justify-between text-xs mb-1">
<span className="text-gray-400">Release</span>
<span className="text-blue-400 font-mono">{release}s</span>
</div>
<input
type="range"
min="0.1"
max="2"
step="0.1"
value={release}
onChange={(e) => setRelease(e.target.value)}
className="w-full h-2 bg-gray-700 rounded-lg appearance-none cursor-pointer accent-blue-500"
/>
</div>
</div>
</div>
{/* Section 3: Master */}
<div className="space-y-4">
<div className="flex items-center gap-2 text-purple-400 border-b border-gray-700 pb-2 mb-2">
<Volume2 size={16} />
<h3 className="font-bold text-sm uppercase tracking-wider">Output</h3>
</div>
<div className="flex items-center justify-center h-full pb-4">
<div className="relative group">
<input
type="range"
min="0"
max="0.5"
step="0.01"
value={volume}
onChange={(e) => setVolume(e.target.value)}
className="w-32 h-32 -rotate-90 bg-transparent cursor-pointer appearance-none z-20 relative"
style={{
background: `linear-gradient(to right, #a855f7 ${volume * 200}%, #374151 ${volume * 200}%)`,
borderRadius: '8px',
height: '8px'
}}
/>
<div className="absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 text-center pointer-events-none">
<span className="block text-2xl font-bold text-white">{Math.round(volume * 200)}%</span>
<span className="text-xs text-purple-400">GAIN</span>
</div>
</div>
</div>
</div>
</div>
{/* Keyboard */}
<div className="bg-gray-950 p-4 sm:p-8 flex justify-center overflow-x-auto">
<div className="relative flex h-48 select-none">
{NOTES.map((note, i) => {
const isActive = activeKeys.has(note.note);
if (note.type === 'white') {
return (
<div
key={note.note}
onMouseDown={() => handleMouseDown(note.freq, note.note)}
onMouseUp={() => handleMouseUp(note.freq, note.note)}
onMouseLeave={() => handleMouseUp(note.freq, note.note)}
className={`
relative w-12 sm:w-16 h-full bg-white border border-gray-300 rounded-b-lg mx-[1px] z-10 cursor-pointer transition-colors duration-75 active:bg-gray-200
${isActive ? 'bg-green-100 !border-green-500 shadow-[inset_0_-10px_20px_rgba(74,222,128,0.5)] translate-y-1' : ''}
`}
>
<div className="absolute bottom-4 w-full text-center text-gray-400 font-mono text-xs pointer-events-none">
<span className="block font-bold text-gray-600">{note.note}</span>
<span className="text-[10px] uppercase opacity-50">({note.key.toUpperCase()})</span>
</div>
</div>
);
}
// Black Keys (Rendered differently to overlay)
return (
<div
key={note.note}
onMouseDown={() => handleMouseDown(note.freq, note.note)}
onMouseUp={() => handleMouseUp(note.freq, note.note)}
onMouseLeave={() => handleMouseUp(note.freq, note.note)}
className={`
absolute w-8 sm:w-10 h-32 bg-gray-900 border border-gray-800 rounded-b-lg z-20 cursor-pointer -ml-4 sm:-ml-5 transition-transform duration-75
${isActive ? 'bg-gray-800 border-green-500 shadow-[0_0_15px_rgba(74,222,128,0.3)]' : ''}
`}
style={{ left: `${(NOTES.slice(0, i).filter(n => n.type === 'white').length) * (window.innerWidth < 640 ? 48 : 64)}px` }}
>
<div className="absolute bottom-4 w-full text-center text-gray-500 font-mono text-[10px] pointer-events-none">
{note.key.toUpperCase()}
</div>
</div>
);
})}
</div>
</div>
</div>
<div className="mt-6 text-center text-gray-500 text-sm max-w-lg">
<p>Click <span className="text-green-400 font-bold">START ENGINE</span> to begin.</p>
<p className="mt-2">Use your mouse or computer keyboard (A, W, S, E, D...) to play.</p>
</div>
</div>
);
};
export default App;
Step 1 Completed: The Core Engine
We have successfully built the foundation! Here is what the synthesizer handles now:
Polyphonic Sound: You can play chords (e.g., C, E, and G together).
Multiple Waveforms: You can switch between Sawtooth (buzzy), Square (video game style), Triangle (flute-like), and Sine (pure smooth tone).
Basic ADSR Envelope:
Attack: How fast the sound fades in.
Release: How long the sound lingers after you let go of the key.
Real-time Visualization: An oscilloscope that draws the actual sound wave being generated by the browser.
How to use it:
Click "START ENGINE" (Browsers require a user interaction to start audio).
Use your mouse to click the piano keys.
Or, use your keyboard!
A= CW= C#S= D(and so on across the home row)
What should we do for Step 2?
Filters: Add a Low Pass Filter (LPF) with Cutoff and Resonance knobs to sculpt the sound (make it "wub").
LFO: Add a Low Frequency Oscillator to wobble the pitch or filter automatically.
Delay/Echo: Add effects to make it sound spacy.
Let me know which direction you want to go!
Filters
Great! Let's move to Step 2: Filters.
A filter is what gives a synthesizer its "character." It subtracts frequencies from the raw buzzing sound of the oscillators.
What we are adding:
Filter Node: A
BiquadFilterNodeinserted into the audio chain.Cutoff (Frequency): Removes frequencies above a certain point (for Low Pass). This makes the sound "darker" or "muffled."
Resonance (Q): Boosts the volume right at the cutoff point. This creates that "squelchy" or "whistling" sound associated with techno and funk.
Type: Switch between Low Pass (LPF), High Pass (HPF), and Band Pass (BPF).
The New Signal Chain:
Oscillator → Envelope Gain → Filter → Master Volume → Speakers
Here is the updated code. I have added a new "Filter" section in the UI and updated the audio routing.
import React, { useState, useEffect, useRef } from 'react';
import { Volume2, Activity, Zap, Settings, Music, Waves, Sliders } from 'lucide-react';
// Frequency data (Same as before)
const NOTES = [
{ note: 'C', freq: 261.63, key: 'a', type: 'white' },
{ note: 'C#', freq: 277.18, key: 'w', type: 'black' },
{ note: 'D', freq: 293.66, key: 's', type: 'white' },
{ note: 'D#', freq: 311.13, key: 'e', type: 'black' },
{ note: 'E', freq: 329.63, key: 'd', type: 'white' },
{ note: 'F', freq: 349.23, key: 'f', type: 'white' },
{ note: 'F#', freq: 369.99, key: 't', type: 'black' },
{ note: 'G', freq: 392.00, key: 'g', type: 'white' },
{ note: 'G#', freq: 415.30, key: 'y', type: 'black' },
{ note: 'A', freq: 440.00, key: 'h', type: 'white' },
{ note: 'A#', freq: 466.16, key: 'u', type: 'black' },
{ note: 'B', freq: 493.88, key: 'j', type: 'white' },
{ note: 'C2', freq: 523.25, key: 'k', type: 'white' },
];
const App = () => {
// --- Audio Context State ---
const audioCtxRef = useRef(null);
const masterGainRef = useRef(null);
const filterRef = useRef(null); // <--- NEW: Filter Node Reference
const analyzerRef = useRef(null);
const activeOscillators = useRef(new Map());
// --- UI State ---
const [isInitialized, setIsInitialized] = useState(false);
// Oscillator
const [waveform, setWaveform] = useState('sawtooth');
// Envelope
const [attack, setAttack] = useState(0.01);
const [release, setRelease] = useState(0.5);
// Filter (NEW STATE)
const [filterType, setFilterType] = useState('lowpass');
const [filterFreq, setFilterFreq] = useState(2000); // Default 2kHz (semi-open)
const [filterQ, setFilterQ] = useState(1); // Resonance
// Master
const [volume, setVolume] = useState(0.5);
const [activeKeys, setActiveKeys] = useState(new Set());
// Visualizer
const canvasRef = useRef(null);
const animationRef = useRef(null);
// --- Audio Engine Initialization ---
const initAudio = () => {
if (audioCtxRef.current) return;
const AudioContext = window.AudioContext || window.webkitAudioContext;
const ctx = new AudioContext();
// 1. Create Nodes
const mainGain = ctx.createGain();
const filter = ctx.createBiquadFilter(); // <--- NEW: Create Filter
const analyzer = ctx.createAnalyser();
// 2. Configure Initial Values
mainGain.gain.value = volume;
filter.type = filterType;
filter.frequency.value = filterFreq;
filter.Q.value = filterQ;
analyzer.fftSize = 2048;
// 3. Routing: Filter -> MasterGain -> Analyzer -> Speakers
// Notes will connect TO the Filter.
filter.connect(mainGain);
mainGain.connect(analyzer);
analyzer.connect(ctx.destination);
// 4. Store Refs
audioCtxRef.current = ctx;
masterGainRef.current = mainGain;
filterRef.current = filter;
analyzerRef.current = analyzer;
setIsInitialized(true);
drawVisualizer();
};
// --- Real-time Parameter Updates ---
// Update Volume
useEffect(() => {
if (masterGainRef.current && audioCtxRef.current) {
masterGainRef.current.gain.setTargetAtTime(volume, audioCtxRef.current.currentTime, 0.1);
}
}, [volume]);
// Update Filter (NEW)
useEffect(() => {
if (filterRef.current && audioCtxRef.current) {
const now = audioCtxRef.current.currentTime;
filterRef.current.type = filterType;
// Use linear ramp for smooth filter sweeps prevents "zipper" noise
filterRef.current.frequency.linearRampToValueAtTime(filterFreq, now + 0.1);
filterRef.current.Q.linearRampToValueAtTime(filterQ, now + 0.1);
}
}, [filterType, filterFreq, filterQ]);
// --- Sound Generation ---
const playNote = (freq) => {
if (!audioCtxRef.current) initAudio();
if (audioCtxRef.current.state === 'suspended') audioCtxRef.current.resume();
if (activeOscillators.current.has(freq)) return;
const ctx = audioCtxRef.current;
const now = ctx.currentTime;
const osc = ctx.createOscillator();
osc.type = waveform;
osc.frequency.setValueAtTime(freq, now);
const noteGain = ctx.createGain();
noteGain.gain.setValueAtTime(0, now);
noteGain.gain.linearRampToValueAtTime(1, now + parseFloat(attack));
// ROUTING CHANGE: Connect Note -> Filter (instead of Master)
osc.connect(noteGain);
noteGain.connect(filterRef.current);
osc.start(now);
activeOscillators.current.set(freq, { osc, noteGain });
};
const stopNote = (freq) => {
const activeNote = activeOscillators.current.get(freq);
if (activeNote && audioCtxRef.current) {
const { osc, noteGain } = activeNote;
const now = audioCtxRef.current.currentTime;
noteGain.gain.cancelScheduledValues(now);
noteGain.gain.setValueAtTime(noteGain.gain.value, now);
noteGain.gain.exponentialRampToValueAtTime(0.001, now + parseFloat(release));
osc.stop(now + parseFloat(release) + 0.1);
setTimeout(() => {
// Cleanup handled by GC mostly, just ensuring map stays clean
}, parseFloat(release) * 1000);
activeOscillators.current.delete(freq);
}
};
// --- Input Handling (Same as Step 1) ---
const handleKeyDown = (e) => {
if (e.repeat) return;
const noteData = NOTES.find(n => n.key === e.key.toLowerCase());
if (noteData) {
setActiveKeys(prev => new Set(prev).add(noteData.note));
playNote(noteData.freq);
}
};
const handleKeyUp = (e) => {
const noteData = NOTES.find(n => n.key === e.key.toLowerCase());
if (noteData) {
setActiveKeys(prev => {
const next = new Set(prev);
next.delete(noteData.note);
return next;
});
stopNote(noteData.freq);
}
};
const handleMouseDown = (freq, note) => {
setActiveKeys(prev => new Set(prev).add(note));
playNote(freq);
};
const handleMouseUp = (freq, note) => {
setActiveKeys(prev => {
const next = new Set(prev);
next.delete(note);
return next;
});
stopNote(freq);
};
useEffect(() => {
window.addEventListener('keydown', handleKeyDown);
window.addEventListener('keyup', handleKeyUp);
return () => {
window.removeEventListener('keydown', handleKeyDown);
window.removeEventListener('keyup', handleKeyUp);
};
}, [waveform, attack, release, filterType]);
// --- Visualization (Same as Step 1) ---
const drawVisualizer = () => {
if (!canvasRef.current || !analyzerRef.current) return;
const canvas = canvasRef.current;
const canvasCtx = canvas.getContext('2d');
const analyzer = analyzerRef.current;
const bufferLength = analyzer.frequencyBinCount;
const dataArray = new Uint8Array(bufferLength);
const draw = () => {
animationRef.current = requestAnimationFrame(draw);
analyzer.getByteTimeDomainData(dataArray);
canvasCtx.fillStyle = '#111827';
canvasCtx.fillRect(0, 0, canvas.width, canvas.height);
canvasCtx.lineWidth = 2;
canvasCtx.strokeStyle = '#facc15'; // Changed color to Yellow/Gold for Step 2
canvasCtx.beginPath();
const sliceWidth = canvas.width * 1.0 / bufferLength;
let x = 0;
for (let i = 0; i < bufferLength; i++) {
const v = dataArray[i] / 128.0;
const y = v * canvas.height / 2;
if (i === 0) canvasCtx.moveTo(x, y);
else canvasCtx.lineTo(x, y);
x += sliceWidth;
}
canvasCtx.lineTo(canvas.width, canvas.height / 2);
canvasCtx.stroke();
};
draw();
};
return (
<div className="min-h-screen bg-gray-900 text-gray-100 font-sans selection:bg-yellow-500 selection:text-black p-4 sm:p-8 flex flex-col items-center justify-center">
{/* Header */}
<div className="w-full max-w-5xl mb-6 flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="p-2 bg-yellow-500 rounded-lg text-gray-900">
<Music size={24} />
</div>
<div>
<h1 className="text-2xl font-bold tracking-wider text-white">NEO-SYNTH <span className="text-yellow-400 text-sm font-mono">v2.0 Filters</span></h1>
<p className="text-gray-400 text-xs uppercase tracking-widest">Polyphonic Web Oscillator</p>
</div>
</div>
{!isInitialized && (
<button
onClick={initAudio}
className="flex items-center gap-2 bg-yellow-500 hover:bg-yellow-400 text-gray-900 px-6 py-2 rounded-full font-bold transition-all shadow-[0_0_20px_rgba(250,204,21,0.4)] animate-pulse"
>
<Zap size={18} />
START ENGINE
</button>
)}
</div>
{/* Main Synth Panel */}
<div className="w-full max-w-5xl bg-gray-800 border border-gray-700 rounded-xl shadow-2xl overflow-hidden flex flex-col">
{/* Visualizer */}
<div className="relative h-40 bg-black border-b border-gray-700">
<canvas ref={canvasRef} width={1000} height={200} className="w-full h-full opacity-80"/>
<div className="absolute top-4 right-4 flex items-center gap-2 text-xs text-yellow-500 font-mono bg-gray-900/80 px-2 py-1 rounded border border-yellow-500/30">
<Activity size={14} />
OSCILLOSCOPE
</div>
</div>
{/* CONTROLS GRID */}
<div className="p-6 grid grid-cols-1 md:grid-cols-4 gap-6 bg-gradient-to-b from-gray-800 to-gray-900">
{/* 1. Oscillator */}
<div className="space-y-4 border-r border-gray-700 md:pr-6">
<div className="flex items-center gap-2 text-green-400 border-b border-gray-700 pb-2 mb-2">
<Settings size={16} />
<h3 className="font-bold text-sm uppercase tracking-wider">Source</h3>
</div>
<div className="space-y-3">
<label className="text-xs text-gray-400 uppercase font-semibold">Waveform</label>
<div className="grid grid-cols-2 gap-2">
{['sawtooth', 'square', 'triangle', 'sine'].map(type => (
<button
key={type}
onClick={() => setWaveform(type)}
className={`px-2 py-2 text-[10px] font-bold uppercase rounded border transition-all ${
waveform === type
? 'bg-green-500 text-gray-900 border-green-500 shadow-lg'
: 'bg-gray-700 text-gray-300 border-gray-600 hover:bg-gray-600'
}`}
>
{type}
</button>
))}
</div>
</div>
</div>
{/* 2. Filter (NEW) */}
<div className="space-y-4 border-r border-gray-700 md:pr-6">
<div className="flex items-center gap-2 text-yellow-400 border-b border-gray-700 pb-2 mb-2">
<Waves size={16} />
<h3 className="font-bold text-sm uppercase tracking-wider">Filter</h3>
</div>
<div className="flex gap-2 mb-4">
{['lowpass', 'highpass', 'bandpass'].map(t => (
<button
key={t}
onClick={() => setFilterType(t)}
title={t}
className={`flex-1 py-1 text-[10px] uppercase rounded ${filterType === t ? 'bg-yellow-500 text-black font-bold' : 'bg-gray-700 text-gray-400'}`}
>
{t === 'lowpass' ? 'LP' : t === 'highpass' ? 'HP' : 'BP'}
</button>
))}
</div>
<div className="space-y-4">
<div>
<div className="flex justify-between text-xs mb-1">
<span className="text-gray-400">Cutoff</span>
<span className="text-yellow-400 font-mono">{filterFreq}Hz</span>
</div>
<input
type="range" min="20" max="10000" step="10"
value={filterFreq}
onChange={(e) => setFilterFreq(Number(e.target.value))}
className="w-full h-2 bg-gray-700 rounded-lg appearance-none cursor-pointer accent-yellow-500"
/>
</div>
<div>
<div className="flex justify-between text-xs mb-1">
<span className="text-gray-400">Resonance</span>
<span className="text-yellow-400 font-mono">{filterQ}</span>
</div>
<input
type="range" min="0" max="20" step="0.1"
value={filterQ}
onChange={(e) => setFilterQ(Number(e.target.value))}
className="w-full h-2 bg-gray-700 rounded-lg appearance-none cursor-pointer accent-yellow-500"
/>
</div>
</div>
</div>
{/* 3. Envelope */}
<div className="space-y-4 border-r border-gray-700 md:pr-6">
<div className="flex items-center gap-2 text-blue-400 border-b border-gray-700 pb-2 mb-2">
<Activity size={16} />
<h3 className="font-bold text-sm uppercase tracking-wider">Envelope</h3>
</div>
<div className="space-y-4">
<div>
<div className="flex justify-between text-xs mb-1">
<span className="text-gray-400">Attack</span>
<span className="text-blue-400 font-mono">{attack}s</span>
</div>
<input
type="range" min="0.001" max="1" step="0.01"
value={attack}
onChange={(e) => setAttack(e.target.value)}
className="w-full h-2 bg-gray-700 rounded-lg appearance-none cursor-pointer accent-blue-500"
/>
</div>
<div>
<div className="flex justify-between text-xs mb-1">
<span className="text-gray-400">Release</span>
<span className="text-blue-400 font-mono">{release}s</span>
</div>
<input
type="range" min="0.1" max="2" step="0.1"
value={release}
onChange={(e) => setRelease(e.target.value)}
className="w-full h-2 bg-gray-700 rounded-lg appearance-none cursor-pointer accent-blue-500"
/>
</div>
</div>
</div>
{/* 4. Output */}
<div className="space-y-4 flex flex-col justify-between">
<div className="flex items-center gap-2 text-purple-400 border-b border-gray-700 pb-2 mb-2">
<Volume2 size={16} />
<h3 className="font-bold text-sm uppercase tracking-wider">Master</h3>
</div>
<div className="flex flex-col items-center justify-center flex-grow">
<div className="relative group mb-4">
<input
type="range" min="0" max="0.5" step="0.01"
value={volume}
onChange={(e) => setVolume(e.target.value)}
className="w-24 h-24 -rotate-90 bg-transparent cursor-pointer appearance-none z-20 relative"
style={{
background: `linear-gradient(to right, #a855f7 ${volume * 200}%, #374151 ${volume * 200}%)`,
borderRadius: '8px',
height: '8px'
}}
/>
</div>
<div className="text-center">
<span className="block text-xl font-bold text-white">{Math.round(volume * 200)}%</span>
<span className="text-[10px] text-purple-400 tracking-widest">VOLUME</span>
</div>
</div>
</div>
</div>
{/* Keyboard */}
<div className="bg-gray-950 p-4 flex justify-center overflow-x-auto">
<div className="relative flex h-40 select-none">
{NOTES.map((note, i) => {
const isActive = activeKeys.has(note.note);
if (note.type === 'white') {
return (
<div
key={note.note}
onMouseDown={() => handleMouseDown(note.freq, note.note)}
onMouseUp={() => handleMouseUp(note.freq, note.note)}
onMouseLeave={() => handleMouseUp(note.freq, note.note)}
className={`
relative w-12 h-full bg-white border border-gray-300 rounded-b-md mx-[1px] z-10 cursor-pointer transition-colors duration-75
${isActive ? 'bg-yellow-100 !border-yellow-500 shadow-[inset_0_-10px_20px_rgba(250,204,21,0.5)] translate-y-1' : ''}
`}
>
<div className="absolute bottom-4 w-full text-center text-gray-400 font-mono text-[10px] pointer-events-none">
<span className="block font-bold text-gray-600">{note.note}</span>
<span className="opacity-50">{note.key}</span>
</div>
</div>
);
}
return (
<div
key={note.note}
onMouseDown={() => handleMouseDown(note.freq, note.note)}
onMouseUp={() => handleMouseUp(note.freq, note.note)}
onMouseLeave={() => handleMouseUp(note.freq, note.note)}
className={`
absolute w-8 h-24 bg-gray-900 border border-gray-800 rounded-b-md z-20 cursor-pointer -ml-4 transition-transform duration-75
${isActive ? 'bg-gray-800 border-yellow-500 shadow-[0_0_15px_rgba(250,204,21,0.3)]' : ''}
`}
style={{ left: `${(NOTES.slice(0, i).filter(n => n.type === 'white').length) * 48}px` }}
/>
);
})}
</div>
</div>
</div>
<div className="mt-6 text-center text-gray-500 text-sm">
<p className="font-mono text-xs text-yellow-500 mb-2"> TRY: Set Waveform to "Sawtooth", Cutoff to ~500Hz, and Resonance to 10. </p>
</div>
</div>
);
};
export default App;
LFO
This is where things get fun. Adding an LFO (Low Frequency Oscillator) transforms a static beep into a living, breathing sound.
Step 3: The LFO Module
We are adding a third oscillator that you don't hear directly. Instead, it robotically turns knobs for you.
We will add:
LFO Rate: How fast it wobbles.
LFO Depth: How intense the wobble is.
Routing (Target):
Pitch: Creates "Vibrato" (spooky/scifi sound).
Filter: Creates "Wobble" (dubstep/funk sound).
Tremolo: Modulates volume (classic electric piano feel).
The Logic:
When you select a target, we physically wire the LFO Gain Node to the Detune parameter of your oscillators or the Frequency parameter of your filter inside the Audio Context.
Here is the complete code for Step 3:
import React, { useState, useEffect, useRef } from 'react';
import { Volume2, Activity, Zap, Settings, Music, Waves, Sliders, Radio } from 'lucide-react';
const NOTES = [
{ note: 'C', freq: 261.63, key: 'a', type: 'white' },
{ note: 'C#', freq: 277.18, key: 'w', type: 'black' },
{ note: 'D', freq: 293.66, key: 's', type: 'white' },
{ note: 'D#', freq: 311.13, key: 'e', type: 'black' },
{ note: 'E', freq: 329.63, key: 'd', type: 'white' },
{ note: 'F', freq: 349.23, key: 'f', type: 'white' },
{ note: 'F#', freq: 369.99, key: 't', type: 'black' },
{ note: 'G', freq: 392.00, key: 'g', type: 'white' },
{ note: 'G#', freq: 415.30, key: 'y', type: 'black' },
{ note: 'A', freq: 440.00, key: 'h', type: 'white' },
{ note: 'A#', freq: 466.16, key: 'u', type: 'black' },
{ note: 'B', freq: 493.88, key: 'j', type: 'white' },
{ note: 'C2', freq: 523.25, key: 'k', type: 'white' },
];
const App = () => {
// --- Audio References ---
const audioCtxRef = useRef(null);
const masterGainRef = useRef(null);
const filterRef = useRef(null);
const analyzerRef = useRef(null);
// LFO References
const lfoOscRef = useRef(null);
const lfoGainRef = useRef(null); // Controls Depth
const activeOscillators = useRef(new Map());
// --- State ---
const [isInitialized, setIsInitialized] = useState(false);
const [activeKeys, setActiveKeys] = useState(new Set());
// Oscillator
const [waveform, setWaveform] = useState('sawtooth');
// Envelope
const [attack, setAttack] = useState(0.01);
const [release, setRelease] = useState(0.5);
// Filter
const [filterType, setFilterType] = useState('lowpass');
const [filterFreq, setFilterFreq] = useState(1000);
const [filterQ, setFilterQ] = useState(5);
// LFO (NEW)
const [lfoRate, setLfoRate] = useState(5); // Hz
const [lfoDepth, setLfoDepth] = useState(500); // Amount
const [lfoTarget, setLfoTarget] = useState('filter'); // 'off', 'pitch', 'filter', 'tremolo'
const [lfoWave, setLfoWave] = useState('sine');
// Master
const [volume, setVolume] = useState(0.5);
// Visualizer
const canvasRef = useRef(null);
const animationRef = useRef(null);
// --- Initialization ---
const initAudio = () => {
if (audioCtxRef.current) return;
const AudioContext = window.AudioContext || window.webkitAudioContext;
const ctx = new AudioContext();
// 1. Create Main Nodes
const mainGain = ctx.createGain();
const filter = ctx.createBiquadFilter();
const analyzer = ctx.createAnalyser();
// 2. Create LFO Nodes (NEW)
const lfoOsc = ctx.createOscillator();
const lfoGain = ctx.createGain();
// 3. Initial Settings
mainGain.gain.value = volume;
filter.type = filterType;
filter.frequency.value = filterFreq;
filter.Q.value = filterQ;
analyzer.fftSize = 2048;
// LFO Settings
lfoOsc.frequency.value = lfoRate;
lfoGain.gain.value = lfoDepth;
lfoOsc.type = lfoWave;
lfoOsc.start(); // LFO runs continuously in background
// 4. Wiring
// LFO -> LFO Gain -> (Target decides destination later)
lfoOsc.connect(lfoGain);
// Filter -> Main -> Analyzer -> Speakers
filter.connect(mainGain);
mainGain.connect(analyzer);
analyzer.connect(ctx.destination);
// 5. Store Refs
audioCtxRef.current = ctx;
masterGainRef.current = mainGain;
filterRef.current = filter;
analyzerRef.current = analyzer;
lfoOscRef.current = lfoOsc;
lfoGainRef.current = lfoGain;
// Apply initial LFO routing
updateLfoRouting(ctx, lfoGain, filter, lfoTarget);
setIsInitialized(true);
drawVisualizer();
};
// --- LFO Routing Logic ---
const updateLfoRouting = (ctx, lfoGain, filter, target) => {
// Disconnect from everything first
try { lfoGain.disconnect(); } catch (e) {}
if (target === 'filter') {
// Connect to Filter Frequency
lfoGain.connect(filter.frequency);
}
else if (target === 'tremolo') {
// Connect to Master Gain
lfoGain.connect(masterGainRef.current.gain);
}
else if (target === 'pitch') {
// Pitch is handled per-note in playNote,
// but we need to connect to existing notes here if changed live
activeOscillators.current.forEach(({ osc }) => {
lfoGain.connect(osc.detune);
});
}
};
// --- Effects for Parameter Updates ---
// LFO Parameter Updates
useEffect(() => {
if (!audioCtxRef.current) return;
const ctx = audioCtxRef.current;
const now = ctx.currentTime;
// Update Rate
lfoOscRef.current.frequency.setTargetAtTime(lfoRate, now, 0.1);
// Update Waveform
lfoOscRef.current.type = lfoWave;
// Update Depth (Scaling depends on target)
let scaledDepth = lfoDepth;
if (lfoTarget === 'tremolo') scaledDepth = lfoDepth / 2000; // Gain needs small numbers (0-1)
lfoGainRef.current.gain.setTargetAtTime(scaledDepth, now, 0.1);
}, [lfoRate, lfoDepth, lfoWave, lfoTarget]);
// LFO Target Switching
useEffect(() => {
if (!audioCtxRef.current) return;
updateLfoRouting(audioCtxRef.current, lfoGainRef.current, filterRef.current, lfoTarget);
}, [lfoTarget]);
// Filter & Master Updates (Same as Step 2)
useEffect(() => {
if (!audioCtxRef.current) return;
const now = audioCtxRef.current.currentTime;
filterRef.current.type = filterType;
filterRef.current.frequency.setTargetAtTime(filterFreq, now, 0.1);
filterRef.current.Q.setTargetAtTime(filterQ, now, 0.1);
masterGainRef.current.gain.setTargetAtTime(volume, now, 0.1);
}, [filterType, filterFreq, filterQ, volume]);
// --- Sound Generation ---
const playNote = (freq) => {
if (!audioCtxRef.current) initAudio();
if (audioCtxRef.current.state === 'suspended') audioCtxRef.current.resume();
if (activeOscillators.current.has(freq)) return;
const ctx = audioCtxRef.current;
const now = ctx.currentTime;
const osc = ctx.createOscillator();
osc.type = waveform;
osc.frequency.setValueAtTime(freq, now);
const noteGain = ctx.createGain();
noteGain.gain.setValueAtTime(0, now);
noteGain.gain.linearRampToValueAtTime(1, now + parseFloat(attack));
// Connections
osc.connect(noteGain);
noteGain.connect(filterRef.current);
// ** LFO Connection for Pitch **
if (lfoTarget === 'pitch' && lfoGainRef.current) {
lfoGainRef.current.connect(osc.detune);
}
osc.start(now);
activeOscillators.current.set(freq, { osc, noteGain });
};
const stopNote = (freq) => {
const activeNote = activeOscillators.current.get(freq);
if (activeNote && audioCtxRef.current) {
const { osc, noteGain } = activeNote;
const now = audioCtxRef.current.currentTime;
noteGain.gain.cancelScheduledValues(now);
noteGain.gain.setValueAtTime(noteGain.gain.value, now);
noteGain.gain.exponentialRampToValueAtTime(0.001, now + parseFloat(release));
osc.stop(now + parseFloat(release) + 0.1);
setTimeout(() => {
// Cleanup handled by GC
}, parseFloat(release) * 1000);
activeOscillators.current.delete(freq);
}
};
// --- Input Handling ---
const handleKeyDown = (e) => {
if (e.repeat) return;
const noteData = NOTES.find(n => n.key === e.key.toLowerCase());
if (noteData) {
setActiveKeys(prev => new Set(prev).add(noteData.note));
playNote(noteData.freq);
}
};
const handleKeyUp = (e) => {
const noteData = NOTES.find(n => n.key === e.key.toLowerCase());
if (noteData) {
setActiveKeys(prev => {
const next = new Set(prev);
next.delete(noteData.note);
return next;
});
stopNote(noteData.freq);
}
};
const handleMouseDown = (freq, note) => {
setActiveKeys(prev => new Set(prev).add(note));
playNote(freq);
};
const handleMouseUp = (freq, note) => {
setActiveKeys(prev => {
const next = new Set(prev);
next.delete(note);
return next;
});
stopNote(freq);
};
useEffect(() => {
window.addEventListener('keydown', handleKeyDown);
window.addEventListener('keyup', handleKeyUp);
return () => {
window.removeEventListener('keydown', handleKeyDown);
window.removeEventListener('keyup', handleKeyUp);
};
}, [waveform, attack, release, lfoTarget]);
// --- Visualizer ---
const drawVisualizer = () => {
if (!canvasRef.current || !analyzerRef.current) return;
const canvas = canvasRef.current;
const canvasCtx = canvas.getContext('2d');
const analyzer = analyzerRef.current;
const bufferLength = analyzer.frequencyBinCount;
const dataArray = new Uint8Array(bufferLength);
const draw = () => {
animationRef.current = requestAnimationFrame(draw);
analyzer.getByteTimeDomainData(dataArray);
canvasCtx.fillStyle = '#111827';
canvasCtx.fillRect(0, 0, canvas.width, canvas.height);
canvasCtx.lineWidth = 2;
canvasCtx.strokeStyle = '#ec4899'; // Pink for Step 3
canvasCtx.beginPath();
const sliceWidth = canvas.width * 1.0 / bufferLength;
let x = 0;
for (let i = 0; i < bufferLength; i++) {
const v = dataArray[i] / 128.0;
const y = v * canvas.height / 2;
if (i === 0) canvasCtx.moveTo(x, y);
else canvasCtx.lineTo(x, y);
x += sliceWidth;
}
canvasCtx.lineTo(canvas.width, canvas.height / 2);
canvasCtx.stroke();
};
draw();
};
return (
<div className="min-h-screen bg-gray-900 text-gray-100 font-sans selection:bg-pink-500 selection:text-black p-4 sm:p-8 flex flex-col items-center justify-center">
{/* Header */}
<div className="w-full max-w-6xl mb-6 flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="p-2 bg-pink-500 rounded-lg text-gray-900">
<Music size={24} />
</div>
<div>
<h1 className="text-2xl font-bold tracking-wider text-white">NEO-SYNTH <span className="text-pink-400 text-sm font-mono">v3.0 LFO</span></h1>
<p className="text-gray-400 text-xs uppercase tracking-widest">Polyphonic Modulation Engine</p>
</div>
</div>
{!isInitialized && (
<button
onClick={initAudio}
className="flex items-center gap-2 bg-pink-500 hover:bg-pink-400 text-gray-900 px-6 py-2 rounded-full font-bold transition-all shadow-[0_0_20px_rgba(236,72,153,0.4)] animate-pulse"
>
<Zap size={18} />
START ENGINE
</button>
)}
</div>
{/* Main Synth Panel */}
<div className="w-full max-w-6xl bg-gray-800 border border-gray-700 rounded-xl shadow-2xl overflow-hidden flex flex-col">
{/* Visualizer */}
<div className="relative h-40 bg-black border-b border-gray-700">
<canvas ref={canvasRef} width={1200} height={200} className="w-full h-full opacity-80"/>
<div className="absolute top-4 right-4 flex items-center gap-2 text-xs text-pink-500 font-mono bg-gray-900/80 px-2 py-1 rounded border border-pink-500/30">
<Activity size={14} />
OSCILLOSCOPE
</div>
</div>
{/* CONTROLS GRID */}
<div className="p-6 grid grid-cols-1 md:grid-cols-5 gap-6 bg-gradient-to-b from-gray-800 to-gray-900">
{/* 1. Oscillator */}
<div className="space-y-4 border-r border-gray-700 md:pr-4">
<div className="flex items-center gap-2 text-green-400 border-b border-gray-700 pb-2 mb-2">
<Settings size={16} />
<h3 className="font-bold text-xs uppercase tracking-wider">Osc 1</h3>
</div>
<div className="space-y-3">
<label className="text-[10px] text-gray-400 uppercase font-semibold">Shape</label>
<div className="grid grid-cols-2 gap-1">
{['sawtooth', 'square', 'triangle', 'sine'].map(type => (
<button
key={type}
onClick={() => setWaveform(type)}
className={`px-2 py-2 text-[9px] font-bold uppercase rounded border transition-all ${
waveform === type
? 'bg-green-500 text-gray-900 border-green-500 shadow-lg'
: 'bg-gray-700 text-gray-300 border-gray-600 hover:bg-gray-600'
}`}
>
{type}
</button>
))}
</div>
</div>
</div>
{/* 2. Filter */}
<div className="space-y-4 border-r border-gray-700 md:pr-4">
<div className="flex items-center gap-2 text-yellow-400 border-b border-gray-700 pb-2 mb-2">
<Waves size={16} />
<h3 className="font-bold text-xs uppercase tracking-wider">Filter</h3>
</div>
<div className="flex gap-1 mb-4">
{['lowpass', 'highpass', 'bandpass'].map(t => (
<button
key={t}
onClick={() => setFilterType(t)}
title={t}
className={`flex-1 py-1 text-[9px] uppercase rounded ${filterType === t ? 'bg-yellow-500 text-black font-bold' : 'bg-gray-700 text-gray-400'}`}
>
{t === 'lowpass' ? 'LP' : t === 'highpass' ? 'HP' : 'BP'}
</button>
))}
</div>
<div className="space-y-2">
<div>
<div className="flex justify-between text-[10px] mb-1">
<span className="text-gray-400">Cutoff</span>
<span className="text-yellow-400 font-mono">{filterFreq}Hz</span>
</div>
<input
type="range" min="20" max="10000" step="10"
value={filterFreq}
onChange={(e) => setFilterFreq(Number(e.target.value))}
className="w-full h-1 bg-gray-700 rounded-lg appearance-none cursor-pointer accent-yellow-500"
/>
</div>
<div>
<div className="flex justify-between text-[10px] mb-1">
<span className="text-gray-400">Resonance</span>
<span className="text-yellow-400 font-mono">{filterQ}</span>
</div>
<input
type="range" min="0" max="20" step="0.1"
value={filterQ}
onChange={(e) => setFilterQ(Number(e.target.value))}
className="w-full h-1 bg-gray-700 rounded-lg appearance-none cursor-pointer accent-yellow-500"
/>
</div>
</div>
</div>
{/* 3. LFO (NEW SECTION) */}
<div className="space-y-4 border-r border-gray-700 md:pr-4 bg-gray-800/50 rounded p-2 border border-pink-500/20">
<div className="flex items-center gap-2 text-pink-400 border-b border-pink-500/30 pb-2 mb-2">
<Radio size={16} />
<h3 className="font-bold text-xs uppercase tracking-wider">LFO</h3>
</div>
{/* Target Select */}
<div className="flex gap-1 mb-2">
{['off', 'pitch', 'filter'].map(t => (
<button
key={t}
onClick={() => setLfoTarget(t)}
className={`flex-1 py-1 text-[9px] uppercase rounded border transition-colors ${
lfoTarget === t
? 'bg-pink-500 text-black font-bold border-pink-500'
: 'bg-transparent text-gray-400 border-gray-700 hover:border-pink-500'
}`}
>
{t}
</button>
))}
</div>
<div className="space-y-2">
<div>
<div className="flex justify-between text-[10px] mb-1">
<span className="text-gray-400">Rate</span>
<span className="text-pink-400 font-mono">{lfoRate}Hz</span>
</div>
<input
type="range" min="0.1" max="20" step="0.1"
value={lfoRate}
onChange={(e) => setLfoRate(Number(e.target.value))}
className="w-full h-1 bg-gray-700 rounded-lg appearance-none cursor-pointer accent-pink-500"
/>
</div>
<div>
<div className="flex justify-between text-[10px] mb-1">
<span className="text-gray-400">Depth</span>
<span className="text-pink-400 font-mono">{lfoDepth}</span>
</div>
<input
type="range" min="0" max="2000" step="10"
value={lfoDepth}
onChange={(e) => setLfoDepth(Number(e.target.value))}
className="w-full h-1 bg-gray-700 rounded-lg appearance-none cursor-pointer accent-pink-500"
/>
</div>
{/* LFO Wave Select */}
<div className="flex gap-1 mt-2">
{['sine', 'square'].map(t => (
<button
key={t}
onClick={() => setLfoWave(t)}
className={`flex-1 py-1 text-[8px] uppercase rounded ${lfoWave === t ? 'bg-pink-900/50 text-pink-200 border border-pink-500' : 'bg-gray-800 text-gray-500 border border-gray-700'}`}
>
{t}
</button>
))}
</div>
</div>
</div>
{/* 4. Envelope */}
<div className="space-y-4 border-r border-gray-700 md:pr-4">
<div className="flex items-center gap-2 text-blue-400 border-b border-gray-700 pb-2 mb-2">
<Activity size={16} />
<h3 className="font-bold text-xs uppercase tracking-wider">ADSR</h3>
</div>
<div className="space-y-2">
<div>
<div className="flex justify-between text-[10px] mb-1">
<span className="text-gray-400">Attack</span>
<span className="text-blue-400 font-mono">{attack}s</span>
</div>
<input
type="range" min="0.001" max="1" step="0.01"
value={attack}
onChange={(e) => setAttack(e.target.value)}
className="w-full h-1 bg-gray-700 rounded-lg appearance-none cursor-pointer accent-blue-500"
/>
</div>
<div>
<div className="flex justify-between text-[10px] mb-1">
<span className="text-gray-400">Release</span>
<span className="text-blue-400 font-mono">{release}s</span>
</div>
<input
type="range" min="0.1" max="2" step="0.1"
value={release}
onChange={(e) => setRelease(e.target.value)}
className="w-full h-1 bg-gray-700 rounded-lg appearance-none cursor-pointer accent-blue-500"
/>
</div>
</div>
</div>
{/* 5. Output */}
<div className="space-y-4 flex flex-col justify-between">
<div className="flex items-center gap-2 text-purple-400 border-b border-gray-700 pb-2 mb-2">
<Volume2 size={16} />
<h3 className="font-bold text-xs uppercase tracking-wider">Out</h3>
</div>
<div className="flex flex-col items-center justify-center flex-grow">
<div className="relative group mb-2">
<input
type="range" min="0" max="0.5" step="0.01"
value={volume}
onChange={(e) => setVolume(e.target.value)}
className="w-20 h-20 -rotate-90 bg-transparent cursor-pointer appearance-none z-20 relative"
style={{
background: `linear-gradient(to right, #a855f7 ${volume * 200}%, #374151 ${volume * 200}%)`,
borderRadius: '8px',
height: '6px'
}}
/>
</div>
<div className="text-center">
<span className="block text-lg font-bold text-white">{Math.round(volume * 200)}%</span>
</div>
</div>
</div>
</div>
{/* Keyboard */}
<div className="bg-gray-950 p-4 flex justify-center overflow-x-auto">
<div className="relative flex h-40 select-none">
{NOTES.map((note, i) => {
const isActive = activeKeys.has(note.note);
if (note.type === 'white') {
return (
<div
key={note.note}
onMouseDown={() => handleMouseDown(note.freq, note.note)}
onMouseUp={() => handleMouseUp(note.freq, note.note)}
onMouseLeave={() => handleMouseUp(note.freq, note.note)}
className={`
relative w-10 md:w-12 h-full bg-white border border-gray-300 rounded-b-md mx-[1px] z-10 cursor-pointer transition-colors duration-75
${isActive ? 'bg-pink-100 !border-pink-500 shadow-[inset_0_-10px_20px_rgba(236,72,153,0.5)] translate-y-1' : ''}
`}
>
<div className="absolute bottom-4 w-full text-center text-gray-400 font-mono text-[10px] pointer-events-none">
<span className="block font-bold text-gray-600">{note.note}</span>
<span className="opacity-50">{note.key}</span>
</div>
</div>
);
}
return (
<div
key={note.note}
onMouseDown={() => handleMouseDown(note.freq, note.note)}
onMouseUp={() => handleMouseUp(note.freq, note.note)}
onMouseLeave={() => handleMouseUp(note.freq, note.note)}
className={`
absolute w-6 md:w-8 h-24 bg-gray-900 border border-gray-800 rounded-b-md z-20 cursor-pointer -ml-3 md:-ml-4 transition-transform duration-75
${isActive ? 'bg-gray-800 border-pink-500 shadow-[0_0_15px_rgba(236,72,153,0.3)]' : ''}
`}
style={{ left: `${(NOTES.slice(0, i).filter(n => n.type === 'white').length) * (window.innerWidth < 768 ? 40 : 48)}px` }}
/>
);
})}
</div>
</div>
</div>
<div className="mt-6 text-center text-gray-500 text-sm">
<p className="font-mono text-xs text-pink-500 mb-2">
<span className="font-bold">TIP:</span> Set LFO Target to "FILTER" and Rate to "8Hz" for a wub-wub sound.
</p>
</div>
</div>
);
};
export default App;
Delay/Echo
This is the final polish. Adding Delay (Echo) turns a dry synthesizer into an atmospheric instrument. It adds space, depth, and rhythm.
Step 4: Space & Time
We are adding a "Send Effect." We split the audio signal after the filter:
Dry Path: Goes straight to the speakers (so you hear the note immediately).
Wet Path (Delay): Goes into a time loop before going to the speakers.
The Delay Circuit:
Delay Node: Holds the sound in a buffer for
Xseconds.Feedback Loop: Takes the output of the delay and feeds it back into the input. This creates the repeats.
Warning: If Feedback > 100%, the sound gets louder every repeat until it destroys your ears. We cap it at 90%.
Mix: Controls how loud the echo is compared to the original sound.
Here is the complete, final synthesizer code with the new Delay Module added on the right.
import React, { useState, useEffect, useRef } from 'react';
import { Volume2, Activity, Zap, Settings, Music, Waves, Radio, Timer } from 'lucide-react';
const NOTES = [
{ note: 'C', freq: 261.63, key: 'a', type: 'white' },
{ note: 'C#', freq: 277.18, key: 'w', type: 'black' },
{ note: 'D', freq: 293.66, key: 's', type: 'white' },
{ note: 'D#', freq: 311.13, key: 'e', type: 'black' },
{ note: 'E', freq: 329.63, key: 'd', type: 'white' },
{ note: 'F', freq: 349.23, key: 'f', type: 'white' },
{ note: 'F#', freq: 369.99, key: 't', type: 'black' },
{ note: 'G', freq: 392.00, key: 'g', type: 'white' },
{ note: 'G#', freq: 415.30, key: 'y', type: 'black' },
{ note: 'A', freq: 440.00, key: 'h', type: 'white' },
{ note: 'A#', freq: 466.16, key: 'u', type: 'black' },
{ note: 'B', freq: 493.88, key: 'j', type: 'white' },
{ note: 'C2', freq: 523.25, key: 'k', type: 'white' },
];
const App = () => {
// --- Audio Core ---
const audioCtxRef = useRef(null);
const masterGainRef = useRef(null);
const filterRef = useRef(null);
const analyzerRef = useRef(null);
// LFO Core
const lfoOscRef = useRef(null);
const lfoGainRef = useRef(null);
// Delay Core (NEW)
const delayNodeRef = useRef(null);
const feedbackGainRef = useRef(null);
const delayWetGainRef = useRef(null);
const activeOscillators = useRef(new Map());
// --- State ---
const [isInitialized, setIsInitialized] = useState(false);
const [activeKeys, setActiveKeys] = useState(new Set());
// Params
const [waveform, setWaveform] = useState('sawtooth');
const [attack, setAttack] = useState(0.01);
const [release, setRelease] = useState(0.5);
const [filterType, setFilterType] = useState('lowpass');
const [filterFreq, setFilterFreq] = useState(1000);
const [filterQ, setFilterQ] = useState(5);
const [lfoRate, setLfoRate] = useState(5);
const [lfoDepth, setLfoDepth] = useState(500);
const [lfoTarget, setLfoTarget] = useState('filter');
const [lfoWave, setLfoWave] = useState('sine');
// Delay Params (NEW)
const [delayTime, setDelayTime] = useState(0.3); // Seconds
const [delayFeedback, setDelayFeedback] = useState(0.4); // 0.0 - 0.9
const [delayMix, setDelayMix] = useState(0.3); // Volume of repeats
const [volume, setVolume] = useState(0.5);
const canvasRef = useRef(null);
const animationRef = useRef(null);
// --- Audio Engine ---
const initAudio = () => {
if (audioCtxRef.current) return;
const AudioContext = window.AudioContext || window.webkitAudioContext;
const ctx = new AudioContext();
// 1. Master & Analysis
const mainGain = ctx.createGain();
mainGain.gain.value = volume;
const analyzer = ctx.createAnalyser();
analyzer.fftSize = 2048;
// 2. Filter
const filter = ctx.createBiquadFilter();
filter.type = filterType;
filter.frequency.value = filterFreq;
filter.Q.value = filterQ;
// 3. LFO
const lfoOsc = ctx.createOscillator();
const lfoGain = ctx.createGain();
lfoOsc.frequency.value = lfoRate;
lfoGain.gain.value = lfoDepth;
lfoOsc.type = lfoWave;
lfoOsc.start();
lfoOsc.connect(lfoGain);
// 4. Delay Effect (NEW)
const delayNode = ctx.createDelay(5.0); // Max delay 5s
const feedbackGain = ctx.createGain();
const delayWetGain = ctx.createGain();
delayNode.delayTime.value = delayTime;
feedbackGain.gain.value = delayFeedback;
delayWetGain.gain.value = delayMix;
// --- WIRING ---
// Main Chain: Filter -> Master -> Analyzer -> Out
filter.connect(mainGain);
mainGain.connect(analyzer);
analyzer.connect(ctx.destination);
// Delay Chain (Send Effect):
// Filter -> DelayNode -> WetGain -> Master
filter.connect(delayNode);
delayNode.connect(delayWetGain);
delayWetGain.connect(mainGain);
// Feedback Loop: DelayNode -> FeedbackGain -> DelayNode
delayNode.connect(feedbackGain);
feedbackGain.connect(delayNode);
// Store Refs
audioCtxRef.current = ctx;
masterGainRef.current = mainGain;
filterRef.current = filter;
analyzerRef.current = analyzer;
lfoOscRef.current = lfoOsc;
lfoGainRef.current = lfoGain;
// Store Delay Refs
delayNodeRef.current = delayNode;
feedbackGainRef.current = feedbackGain;
delayWetGainRef.current = delayWetGain;
updateLfoRouting(ctx, lfoGain, filter, lfoTarget);
setIsInitialized(true);
drawVisualizer();
};
const updateLfoRouting = (ctx, lfoGain, filter, target) => {
try { lfoGain.disconnect(); } catch (e) {}
if (target === 'filter') lfoGain.connect(filter.frequency);
else if (target === 'tremolo') lfoGain.connect(masterGainRef.current.gain);
else if (target === 'pitch') {
activeOscillators.current.forEach(({ osc }) => lfoGain.connect(osc.detune));
}
};
// --- Realtime Parameter Updates ---
useEffect(() => {
if (!audioCtxRef.current) return;
const now = audioCtxRef.current.currentTime;
// Filter
filterRef.current.type = filterType;
filterRef.current.frequency.setTargetAtTime(filterFreq, now, 0.1);
filterRef.current.Q.setTargetAtTime(filterQ, now, 0.1);
// LFO
lfoOscRef.current.frequency.setTargetAtTime(lfoRate, now, 0.1);
lfoOscRef.current.type = lfoWave;
let scaledDepth = lfoDepth;
if (lfoTarget === 'tremolo') scaledDepth = lfoDepth / 2000;
lfoGainRef.current.gain.setTargetAtTime(scaledDepth, now, 0.1);
// Delay Updates
delayNodeRef.current.delayTime.setTargetAtTime(delayTime, now, 0.1);
feedbackGainRef.current.gain.setTargetAtTime(delayFeedback, now, 0.1);
delayWetGainRef.current.gain.setTargetAtTime(delayMix, now, 0.1);
// Master
masterGainRef.current.gain.setTargetAtTime(volume, now, 0.1);
}, [filterType, filterFreq, filterQ, lfoRate, lfoDepth, lfoWave, lfoTarget, delayTime, delayFeedback, delayMix, volume]);
// --- Note Logic ---
const playNote = (freq) => {
if (!audioCtxRef.current) initAudio();
if (audioCtxRef.current.state === 'suspended') audioCtxRef.current.resume();
if (activeOscillators.current.has(freq)) return;
const ctx = audioCtxRef.current;
const now = ctx.currentTime;
const osc = ctx.createOscillator();
osc.type = waveform;
osc.frequency.setValueAtTime(freq, now);
const noteGain = ctx.createGain();
noteGain.gain.setValueAtTime(0, now);
noteGain.gain.linearRampToValueAtTime(1, now + parseFloat(attack));
osc.connect(noteGain);
noteGain.connect(filterRef.current); // Connects to Filter (which feeds Delay & Master)
if (lfoTarget === 'pitch') lfoGainRef.current.connect(osc.detune);
osc.start(now);
activeOscillators.current.set(freq, { osc, noteGain });
};
const stopNote = (freq) => {
const activeNote = activeOscillators.current.get(freq);
if (activeNote && audioCtxRef.current) {
const { osc, noteGain } = activeNote;
const now = audioCtxRef.current.currentTime;
noteGain.gain.cancelScheduledValues(now);
noteGain.gain.setValueAtTime(noteGain.gain.value, now);
noteGain.gain.exponentialRampToValueAtTime(0.001, now + parseFloat(release));
osc.stop(now + parseFloat(release) + 0.1);
activeOscillators.current.delete(freq);
}
};
// --- Input & UI ---
const handleKeyDown = (e) => {
if (e.repeat) return;
const noteData = NOTES.find(n => n.key === e.key.toLowerCase());
if (noteData) {
setActiveKeys(prev => new Set(prev).add(noteData.note));
playNote(noteData.freq);
}
};
const handleKeyUp = (e) => {
const noteData = NOTES.find(n => n.key === e.key.toLowerCase());
if (noteData) {
setActiveKeys(prev => {
const next = new Set(prev);
next.delete(noteData.note);
return next;
});
stopNote(noteData.freq);
}
};
const handleMouseDown = (freq, note) => {
setActiveKeys(prev => new Set(prev).add(note));
playNote(freq);
};
const handleMouseUp = (freq, note) => {
setActiveKeys(prev => {
const next = new Set(prev);
next.delete(note);
return next;
});
stopNote(freq);
};
useEffect(() => {
window.addEventListener('keydown', handleKeyDown);
window.addEventListener('keyup', handleKeyUp);
return () => {
window.removeEventListener('keydown', handleKeyDown);
window.removeEventListener('keyup', handleKeyUp);
};
}, [waveform, attack, release, lfoTarget]);
// --- Visualizer ---
const drawVisualizer = () => {
if (!canvasRef.current || !analyzerRef.current) return;
const canvas = canvasRef.current;
const canvasCtx = canvas.getContext('2d');
const analyzer = analyzerRef.current;
const bufferLength = analyzer.frequencyBinCount;
const dataArray = new Uint8Array(bufferLength);
const draw = () => {
animationRef.current = requestAnimationFrame(draw);
analyzer.getByteTimeDomainData(dataArray);
canvasCtx.fillStyle = '#111827';
canvasCtx.fillRect(0, 0, canvas.width, canvas.height);
canvasCtx.lineWidth = 2;
canvasCtx.strokeStyle = '#38bdf8'; // Sky Blue for Step 4
canvasCtx.beginPath();
const sliceWidth = canvas.width * 1.0 / bufferLength;
let x = 0;
for (let i = 0; i < bufferLength; i++) {
const v = dataArray[i] / 128.0;
const y = v * canvas.height / 2;
if (i === 0) canvasCtx.moveTo(x, y);
else canvasCtx.lineTo(x, y);
x += sliceWidth;
}
canvasCtx.lineTo(canvas.width, canvas.height / 2);
canvasCtx.stroke();
};
draw();
};
return (
<div className="min-h-screen bg-gray-900 text-gray-100 font-sans selection:bg-sky-500 selection:text-black p-4 flex flex-col items-center justify-center">
{/* Header */}
<div className="w-full max-w-7xl mb-6 flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="p-2 bg-sky-500 rounded-lg text-gray-900">
<Music size={24} />
</div>
<div>
<h1 className="text-2xl font-bold tracking-wider text-white">NEO-SYNTH <span className="text-sky-400 text-sm font-mono">v4.0 DELAY</span></h1>
<p className="text-gray-400 text-xs uppercase tracking-widest">Atmospheric Synthesis Engine</p>
</div>
</div>
{!isInitialized && (
<button
onClick={initAudio}
className="flex items-center gap-2 bg-sky-500 hover:bg-sky-400 text-gray-900 px-6 py-2 rounded-full font-bold transition-all shadow-[0_0_20px_rgba(56,189,248,0.4)] animate-pulse"
>
<Zap size={18} />
START ENGINE
</button>
)}
</div>
{/* Main Synth Panel */}
<div className="w-full max-w-7xl bg-gray-800 border border-gray-700 rounded-xl shadow-2xl overflow-hidden flex flex-col">
{/* Visualizer */}
<div className="relative h-32 bg-black border-b border-gray-700">
<canvas ref={canvasRef} width={1400} height={160} className="w-full h-full opacity-80"/>
<div className="absolute top-4 right-4 flex items-center gap-2 text-xs text-sky-500 font-mono bg-gray-900/80 px-2 py-1 rounded border border-sky-500/30">
<Activity size={14} />
OSCILLOSCOPE
</div>
</div>
{/* CONTROLS GRID */}
<div className="p-6 grid grid-cols-1 md:grid-cols-6 gap-4 bg-gradient-to-b from-gray-800 to-gray-900">
{/* 1. Oscillator */}
<div className="space-y-3 border-r border-gray-700 md:pr-4">
<div className="flex items-center gap-2 text-green-400 border-b border-gray-700 pb-2 mb-1">
<Settings size={14} />
<h3 className="font-bold text-[10px] uppercase tracking-wider">Source</h3>
</div>
<div className="grid grid-cols-2 gap-1">
{['sawtooth', 'square', 'triangle', 'sine'].map(type => (
<button
key={type}
onClick={() => setWaveform(type)}
className={`px-1 py-2 text-[9px] font-bold uppercase rounded border transition-all ${
waveform === type
? 'bg-green-500 text-gray-900 border-green-500 shadow-lg'
: 'bg-gray-700 text-gray-300 border-gray-600 hover:bg-gray-600'
}`}
>
{type}
</button>
))}
</div>
</div>
{/* 2. Filter */}
<div className="space-y-3 border-r border-gray-700 md:pr-4">
<div className="flex items-center gap-2 text-yellow-400 border-b border-gray-700 pb-2 mb-1">
<Waves size={14} />
<h3 className="font-bold text-[10px] uppercase tracking-wider">Filter</h3>
</div>
<div className="flex gap-1 mb-2">
{['lowpass', 'highpass'].map(t => (
<button
key={t}
onClick={() => setFilterType(t)}
className={`flex-1 py-1 text-[9px] uppercase rounded ${filterType === t ? 'bg-yellow-500 text-black font-bold' : 'bg-gray-700 text-gray-400'}`}
>
{t === 'lowpass' ? 'LP' : 'HP'}
</button>
))}
</div>
<div className="space-y-1">
<input type="range" min="20" max="10000" value={filterFreq} onChange={(e) => setFilterFreq(Number(e.target.value))} className="w-full h-1 bg-gray-700 rounded-lg appearance-none accent-yellow-500" />
<div className="flex justify-between text-[9px] text-gray-400"><span>Freq</span><span>{filterFreq}Hz</span></div>
</div>
<div className="space-y-1">
<input type="range" min="0" max="20" step="0.1" value={filterQ} onChange={(e) => setFilterQ(Number(e.target.value))} className="w-full h-1 bg-gray-700 rounded-lg appearance-none accent-yellow-500" />
<div className="flex justify-between text-[9px] text-gray-400"><span>Res</span><span>{filterQ}</span></div>
</div>
</div>
{/* 3. Envelope */}
<div className="space-y-3 border-r border-gray-700 md:pr-4">
<div className="flex items-center gap-2 text-blue-400 border-b border-gray-700 pb-2 mb-1">
<Activity size={14} />
<h3 className="font-bold text-[10px] uppercase tracking-wider">ADSR</h3>
</div>
<div className="space-y-1">
<input type="range" min="0.001" max="1" step="0.01" value={attack} onChange={(e) => setAttack(e.target.value)} className="w-full h-1 bg-gray-700 rounded-lg appearance-none accent-blue-500" />
<div className="flex justify-between text-[9px] text-gray-400"><span>Atk</span><span>{attack}s</span></div>
</div>
<div className="space-y-1">
<input type="range" min="0.1" max="2" step="0.1" value={release} onChange={(e) => setRelease(e.target.value)} className="w-full h-1 bg-gray-700 rounded-lg appearance-none accent-blue-500" />
<div className="flex justify-between text-[9px] text-gray-400"><span>Rel</span><span>{release}s</span></div>
</div>
</div>
{/* 4. LFO */}
<div className="space-y-3 border-r border-gray-700 md:pr-4">
<div className="flex items-center gap-2 text-pink-400 border-b border-gray-700 pb-2 mb-1">
<Radio size={14} />
<h3 className="font-bold text-[10px] uppercase tracking-wider">LFO</h3>
</div>
<div className="flex gap-1 mb-2">
{['off', 'pitch', 'filter'].map(t => (
<button key={t} onClick={() => setLfoTarget(t)} className={`flex-1 py-1 text-[8px] uppercase rounded border ${lfoTarget === t ? 'bg-pink-500 text-black font-bold border-pink-500' : 'bg-transparent text-gray-500 border-gray-700'}`}>{t}</button>
))}
</div>
<div className="space-y-1">
<input type="range" min="0.1" max="20" step="0.1" value={lfoRate} onChange={(e) => setLfoRate(Number(e.target.value))} className="w-full h-1 bg-gray-700 rounded-lg appearance-none accent-pink-500" />
<div className="flex justify-between text-[9px] text-gray-400"><span>Rate</span><span>{lfoRate}Hz</span></div>
</div>
<div className="space-y-1">
<input type="range" min="0" max="2000" step="10" value={lfoDepth} onChange={(e) => setLfoDepth(Number(e.target.value))} className="w-full h-1 bg-gray-700 rounded-lg appearance-none accent-pink-500" />
<div className="flex justify-between text-[9px] text-gray-400"><span>Depth</span><span>{lfoDepth}</span></div>
</div>
</div>
{/* 5. DELAY (NEW) */}
<div className="space-y-3 border-r border-gray-700 md:pr-4 bg-gray-800/50 rounded p-2 border border-sky-500/20">
<div className="flex items-center gap-2 text-sky-400 border-b border-sky-500/30 pb-2 mb-1">
<Timer size={14} />
<h3 className="font-bold text-[10px] uppercase tracking-wider">Delay</h3>
</div>
<div className="space-y-2">
<div className="space-y-1">
<input
type="range" min="0" max="1" step="0.01"
value={delayTime}
onChange={(e) => setDelayTime(Number(e.target.value))}
className="w-full h-1 bg-gray-700 rounded-lg appearance-none accent-sky-500"
/>
<div className="flex justify-between text-[9px] text-gray-400"><span>Time</span><span className="text-sky-400">{delayTime}s</span></div>
</div>
<div className="space-y-1">
<input
type="range" min="0" max="0.9" step="0.01"
value={delayFeedback}
onChange={(e) => setDelayFeedback(Number(e.target.value))}
className="w-full h-1 bg-gray-700 rounded-lg appearance-none accent-sky-500"
/>
<div className="flex justify-between text-[9px] text-gray-400"><span>Fdbk</span><span className="text-sky-400">{Math.round(delayFeedback * 100)}%</span></div>
</div>
<div className="space-y-1">
<input
type="range" min="0" max="1" step="0.01"
value={delayMix}
onChange={(e) => setDelayMix(Number(e.target.value))}
className="w-full h-1 bg-gray-700 rounded-lg appearance-none accent-sky-500"
/>
<div className="flex justify-between text-[9px] text-gray-400"><span>Mix</span><span className="text-sky-400">{Math.round(delayMix * 100)}%</span></div>
</div>
</div>
</div>
{/* 6. Master */}
<div className="space-y-4 flex flex-col justify-between">
<div className="flex items-center gap-2 text-purple-400 border-b border-gray-700 pb-2 mb-1">
<Volume2 size={14} />
<h3 className="font-bold text-[10px] uppercase tracking-wider">Out</h3>
</div>
<div className="flex flex-col items-center justify-center flex-grow">
<div className="relative group mb-2">
<input
type="range" min="0" max="0.5" step="0.01"
value={volume}
onChange={(e) => setVolume(e.target.value)}
className="w-16 h-16 -rotate-90 bg-transparent cursor-pointer appearance-none z-20 relative"
style={{
background: `linear-gradient(to right, #a855f7 ${volume * 200}%, #374151 ${volume * 200}%)`,
borderRadius: '8px',
height: '4px'
}}
/>
</div>
</div>
</div>
</div>
{/* Keyboard */}
<div className="bg-gray-950 p-4 flex justify-center overflow-x-auto">
<div className="relative flex h-32 select-none">
{NOTES.map((note, i) => {
const isActive = activeKeys.has(note.note);
if (note.type === 'white') {
return (
<div
key={note.note}
onMouseDown={() => handleMouseDown(note.freq, note.note)}
onMouseUp={() => handleMouseUp(note.freq, note.note)}
onMouseLeave={() => handleMouseUp(note.freq, note.note)}
className={`
relative w-8 md:w-10 h-full bg-white border border-gray-300 rounded-b-md mx-[1px] z-10 cursor-pointer transition-colors duration-75
${isActive ? 'bg-sky-100 !border-sky-500 shadow-[inset_0_-10px_20px_rgba(56,189,248,0.5)] translate-y-1' : ''}
`}
>
<div className="absolute bottom-2 w-full text-center text-gray-400 font-mono text-[9px] pointer-events-none">
<span className="block font-bold text-gray-600">{note.note}</span>
</div>
</div>
);
}
return (
<div
key={note.note}
onMouseDown={() => handleMouseDown(note.freq, note.note)}
onMouseUp={() => handleMouseUp(note.freq, note.note)}
onMouseLeave={() => handleMouseUp(note.freq, note.note)}
className={`
absolute w-5 md:w-6 h-20 bg-gray-900 border border-gray-800 rounded-b-md z-20 cursor-pointer -ml-2.5 md:-ml-3 transition-transform duration-75
${isActive ? 'bg-gray-800 border-sky-500 shadow-[0_0_15px_rgba(56,189,248,0.3)]' : ''}
`}
style={{ left: `${(NOTES.slice(0, i).filter(n => n.type === 'white').length) * (window.innerWidth < 768 ? 32 : 40)}px` }}
/>
);
})}
</div>
</div>
</div>
</div>
);
};
export default App;
Now provide a 15-step tutorial on how to learn to patch
Based on the NEO-SYNTH v4.0 we just built, here is a 15-step tutorial to learn Patching (the art of designing sounds from scratch).
We will move from the raw sound source through the filter, shape it with the envelope, modulate it with the LFO, and finally place it in space with Delay.
Phase 1: The Raw Source (Oscillator)
1. The "Init Patch" (Reset) Before designing a sound, you must always zero out the synth to hear the raw signal.
Source: Sawtooth
Filter: Cutoff Max (10000Hz), Resonance 0.
ADSR: Attack 0.01s, Release 0.1s.
LFO: Target "Off".
Delay: Mix 0.
2. Understanding Waveforms Switch through the Source buttons while playing a low note (e.g., 'A' key).
Sine: Pure, smooth (Sub-bass, flute).
Triangle: Hollow, soft (Gameboy bass, clarinet).
Square: Hollow but harsh (Nintendo sounds, woodwinds).
Sawtooth: Buzzy, rich (Strings, brass, aggressive leads).
Task: Select Square for a retro video game start.
3. Polyphony Check Play a chord (A + S + D keys).
Unlike a Monophonic synth, our engine creates a new oscillator for every key press.
Concept: Notice how the sounds stack up. If it gets too loud, lower the Out (Volume) slider.
Phase 2: Sculpting Tone (Subtractive Synthesis)
4. The Low Pass Filter (LPF) This is the most important tool in synthesis. It cuts high frequencies.
Set Source to Sawtooth.
Drag Filter Freq from 10000Hz down to 200Hz while playing.
Result: The sound goes from "bright/buzzy" to "muffled/warm".
5. Resonance (The "Zap") Resonance boosts the volume exactly at the cutoff point.
Set Filter Freq to ~1000Hz.
Increase Filter Res to 10 or 15.
Result: You hear a whistling tone.
Action: Now sweep the Freq slider up and down. You will hear the classic "laser" or "water drop" sound.
6. The High Pass Filter (HPF)
Switch Filter Type to HP.
Sweep the Freq slider from 20Hz up to 5000Hz.
Result: It cuts the bass, leaving only the sizzle. Useful for thin leads, but we will stick to LP (Low Pass) for the rest of this tutorial.
Phase 3: Shaping Time (Envelope / ADSR)
7. The "Pluck" (Short Percussive Sound) Let's make a sound that hits hard and fades fast (like a guitar or harp).
Attack: Minimum (0.01s).
Release: Short (0.2s).
Filter: Low Pass, Freq 10000Hz.
Result: A sharp, staccato sound.
8. The "Pad" (Slow Atmospheric Sound) Let's make a sound that "swells" in (like a violin or choir).
Attack: High (0.8s - 1.0s).
Release: High (1.0s+).
Action: Tap a key quickly. You might hear nothing! You have to hold the key to let the volume ramp up.
9. The "Keys" (Organ/Piano)
Attack: Very low (0.01s).
Release: Medium (0.4s).
This is the standard "playable" setting for most melodic instruments.
Phase 4: Modulation (LFO)
10. Vibrato (Pitch Modulation)
LFO Target: Pitch.
Rate: ~5 Hz.
Depth: Low (~10-50).
Result: The pitch wobbles slightly, mimicking a singer or violinist.
Extreme: Crank Depth to 500+ for a "broken siren" sound.
11. The "Wobble" (Dubstep Bass) This connects the robot hand (LFO) to the Filter knob.
Source: Sawtooth.
Filter: LP, Freq 500Hz (Low), Res 10 (High).
LFO Target: Filter.
Rate: ~4 Hz to 8 Hz.
Depth: Max (2000).
Result: The "Wub-Wub-Wub" sound. The LFO is opening and closing the filter for you.
12. Tremolo (Volume Modulation)
Source: Triangle or Sine (for an Electric Piano feel).
LFO Target: Tremolo (this isn't in the UI labels but corresponds to
tremoloin code, if you mapped it there, otherwise stick to Filter/Pitch).Note: In our v4.0 code, we mapped 'filter', 'pitch', and 'off'. If you want tremolo, you'd select the 'filter' target but set the filter to fully open, or use Pitch for a "Vibe" pedal sound.
Phase 5: Space (Delay)
13. The Slapback (Small Room)
Delay Time: Very short (0.05s - 0.1s).
Feedback: Low (0.2).
Mix: Medium (0.4).
Result: Sounds like you are playing in a tiled bathroom. Good for funk guitar sounds.
14. The Canyon (Atmosphere)
Delay Time: Long (0.5s+).
Feedback: Medium-High (0.6).
Mix: High (0.5).
Result: The notes repeat and overlap, creating a dreamy texture.
15. The "Self-Oscillation" Danger Zone
Feedback: Max (0.9).
Action: Play a note, then stop. The echo will continue almost forever.
Technique: While the echo is playing, move the Delay Time slider. The pitch of the echoes will warp and stretch (Doppler effect).
Final Challenge: The "Blade Runner" Lead
Try to combine everything to make this specific patch:
Source: Sawtooth.
Filter: LP, Freq ~2000Hz, Res ~5.
ADSR: Attack 0.1s (slight fade in), Release 0.8s (long tail).
LFO: Target Pitch, Rate 3Hz, Depth 20 (Subtle detune).
Delay: Time 0.4s, Feedback 0.5, Mix 0.4.
Play high notes on the keyboard with this setting!
To make the application standalone (meaning it runs in a browser without a build process like Node.js, Webpack, or React), we need to convert the React concepts into standard web technologies.
Here is the conversion strategy:
React Hooks (
useState,useRef) $\rightarrow$ Global JavaScript variables.JSX (HTML in JS) $\rightarrow$ Standard HTML structure.
Tailwind CSS $\rightarrow$ Imported via a CDN link.
Lucide React Icons $\rightarrow$ Imported via a CDN script.
Event Handlers (
onClick,onChange) $\rightarrow$addEventListeneror direct DOM manipulation.
Here is the complete, single-file source code. You can save this as index.html and open it in any browser.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>NEO-SYNTH v4.0 Standalone</title>
<!-- 1. Import Tailwind CSS -->
<script src="https://cdn.tailwindcss.com"></script>
<!-- 2. Import Lucide Icons -->
<script src="https://unpkg.com/lucide@latest"></script>
<style>
/* Custom styles for range inputs to match the React look */
input[type=range] {
-webkit-appearance: none;
background: transparent;
}
input[type=range]::-webkit-slider-thumb {
-webkit-appearance: none;
height: 16px;
width: 16px;
border-radius: 50%;
background: white;
margin-top: -6px;
cursor: pointer;
}
input[type=range]::-webkit-slider-runnable-track {
width: 100%;
height: 4px;
cursor: pointer;
background: #374151;
border-radius: 2px;
}
/* Hide scrollbar for keys */
.no-scrollbar::-webkit-scrollbar {
display: none;
}
.no-scrollbar {
-ms-overflow-style: none;
scrollbar-width: none;
}
</style>
</head>
<body class="bg-gray-900 text-gray-100 font-sans selection:bg-sky-500 selection:text-black p-4 flex flex-col items-center justify-center min-h-screen">
<!-- Header -->
<div class="w-full max-w-7xl mb-6 flex items-center justify-between">
<div class="flex items-center gap-3">
<div class="p-2 bg-sky-500 rounded-lg text-gray-900">
<i data-lucide="music"></i>
</div>
<div>
<h1 class="text-2xl font-bold tracking-wider text-white">NEO-SYNTH <span class="text-sky-400 text-sm font-mono">v4.0 STANDALONE</span></h1>
<p class="text-gray-400 text-xs uppercase tracking-widest">Atmospheric Synthesis Engine</p>
</div>
</div>
<button id="start-btn" class="flex items-center gap-2 bg-sky-500 hover:bg-sky-400 text-gray-900 px-6 py-2 rounded-full font-bold transition-all shadow-[0_0_20px_rgba(56,189,248,0.4)] animate-pulse">
<i data-lucide="zap" width="18"></i>
START ENGINE
</button>
</div>
<!-- Main Synth Panel -->
<div class="w-full max-w-7xl bg-gray-800 border border-gray-700 rounded-xl shadow-2xl overflow-hidden flex flex-col">
<!-- Visualizer -->
<div class="relative h-32 bg-black border-b border-gray-700">
<canvas id="oscilloscope" width="1400" height="160" class="w-full h-full opacity-80"></canvas>
<div class="absolute top-4 right-4 flex items-center gap-2 text-xs text-sky-500 font-mono bg-gray-900/80 px-2 py-1 rounded border border-sky-500/30">
<i data-lucide="activity" width="14"></i>
OSCILLOSCOPE
</div>
</div>
<!-- CONTROLS GRID -->
<div class="p-6 grid grid-cols-1 md:grid-cols-6 gap-4 bg-gradient-to-b from-gray-800 to-gray-900">
<!-- 1. Oscillator -->
<div class="space-y-3 border-r border-gray-700 md:pr-4">
<div class="flex items-center gap-2 text-green-400 border-b border-gray-700 pb-2 mb-1">
<i data-lucide="settings" width="14"></i>
<h3 class="font-bold text-[10px] uppercase tracking-wider">Source</h3>
</div>
<div class="grid grid-cols-2 gap-1" id="waveform-buttons">
<!-- Buttons generated by JS or hardcoded below -->
<button data-wave="sawtooth" class="wave-btn px-1 py-2 text-[9px] font-bold uppercase rounded border bg-green-500 text-gray-900 border-green-500 shadow-lg">sawtooth</button>
<button data-wave="square" class="wave-btn px-1 py-2 text-[9px] font-bold uppercase rounded border bg-gray-700 text-gray-300 border-gray-600">square</button>
<button data-wave="triangle" class="wave-btn px-1 py-2 text-[9px] font-bold uppercase rounded border bg-gray-700 text-gray-300 border-gray-600">triangle</button>
<button data-wave="sine" class="wave-btn px-1 py-2 text-[9px] font-bold uppercase rounded border bg-gray-700 text-gray-300 border-gray-600">sine</button>
</div>
</div>
<!-- 2. Filter -->
<div class="space-y-3 border-r border-gray-700 md:pr-4">
<div class="flex items-center gap-2 text-yellow-400 border-b border-gray-700 pb-2 mb-1">
<i data-lucide="waves" width="14"></i>
<h3 class="font-bold text-[10px] uppercase tracking-wider">Filter</h3>
</div>
<div class="flex gap-1 mb-2" id="filter-type-buttons">
<button data-type="lowpass" class="filter-btn flex-1 py-1 text-[9px] uppercase rounded bg-yellow-500 text-black font-bold">LP</button>
<button data-type="highpass" class="filter-btn flex-1 py-1 text-[9px] uppercase rounded bg-gray-700 text-gray-400">HP</button>
</div>
<div class="space-y-1">
<input type="range" id="filter-freq" min="20" max="10000" value="1000" class="w-full h-1 bg-gray-700 rounded-lg appearance-none accent-yellow-500">
<div class="flex justify-between text-[9px] text-gray-400"><span>Freq</span><span id="val-filter-freq">1000Hz</span></div>
</div>
<div class="space-y-1">
<input type="range" id="filter-q" min="0" max="20" step="0.1" value="5" class="w-full h-1 bg-gray-700 rounded-lg appearance-none accent-yellow-500">
<div class="flex justify-between text-[9px] text-gray-400"><span>Res</span><span id="val-filter-q">5</span></div>
</div>
</div>
<!-- 3. Envelope -->
<div class="space-y-3 border-r border-gray-700 md:pr-4">
<div class="flex items-center gap-2 text-blue-400 border-b border-gray-700 pb-2 mb-1">
<i data-lucide="activity" width="14"></i>
<h3 class="font-bold text-[10px] uppercase tracking-wider">ADSR</h3>
</div>
<div class="space-y-1">
<input type="range" id="env-attack" min="0.001" max="1" step="0.01" value="0.01" class="w-full h-1 bg-gray-700 rounded-lg appearance-none accent-blue-500">
<div class="flex justify-between text-[9px] text-gray-400"><span>Atk</span><span id="val-env-attack">0.01s</span></div>
</div>
<div class="space-y-1">
<input type="range" id="env-release" min="0.1" max="2" step="0.1" value="0.5" class="w-full h-1 bg-gray-700 rounded-lg appearance-none accent-blue-500">
<div class="flex justify-between text-[9px] text-gray-400"><span>Rel</span><span id="val-env-release">0.5s</span></div>
</div>
</div>
<!-- 4. LFO -->
<div class="space-y-3 border-r border-gray-700 md:pr-4">
<div class="flex items-center gap-2 text-pink-400 border-b border-gray-700 pb-2 mb-1">
<i data-lucide="radio" width="14"></i>
<h3 class="font-bold text-[10px] uppercase tracking-wider">LFO</h3>
</div>
<div class="flex gap-1 mb-2" id="lfo-target-buttons">
<button data-target="off" class="lfo-btn flex-1 py-1 text-[8px] uppercase rounded border bg-transparent text-gray-500 border-gray-700">off</button>
<button data-target="pitch" class="lfo-btn flex-1 py-1 text-[8px] uppercase rounded border bg-transparent text-gray-500 border-gray-700">pitch</button>
<button data-target="filter" class="lfo-btn flex-1 py-1 text-[8px] uppercase rounded border bg-pink-500 text-black font-bold border-pink-500">filter</button>
</div>
<div class="space-y-1">
<input type="range" id="lfo-rate" min="0.1" max="20" step="0.1" value="5" class="w-full h-1 bg-gray-700 rounded-lg appearance-none accent-pink-500">
<div class="flex justify-between text-[9px] text-gray-400"><span>Rate</span><span id="val-lfo-rate">5Hz</span></div>
</div>
<div class="space-y-1">
<input type="range" id="lfo-depth" min="0" max="2000" step="10" value="500" class="w-full h-1 bg-gray-700 rounded-lg appearance-none accent-pink-500">
<div class="flex justify-between text-[9px] text-gray-400"><span>Depth</span><span id="val-lfo-depth">500</span></div>
</div>
</div>
<!-- 5. Delay -->
<div class="space-y-3 border-r border-gray-700 md:pr-4 bg-gray-800/50 rounded p-2 border border-sky-500/20">
<div class="flex items-center gap-2 text-sky-400 border-b border-sky-500/30 pb-2 mb-1">
<i data-lucide="timer" width="14"></i>
<h3 class="font-bold text-[10px] uppercase tracking-wider">Delay</h3>
</div>
<div class="space-y-2">
<div class="space-y-1">
<input type="range" id="delay-time" min="0" max="1" step="0.01" value="0.3" class="w-full h-1 bg-gray-700 rounded-lg appearance-none accent-sky-500">
<div class="flex justify-between text-[9px] text-gray-400"><span>Time</span><span id="val-delay-time" class="text-sky-400">0.3s</span></div>
</div>
<div class="space-y-1">
<input type="range" id="delay-feedback" min="0" max="0.9" step="0.01" value="0.4" class="w-full h-1 bg-gray-700 rounded-lg appearance-none accent-sky-500">
<div class="flex justify-between text-[9px] text-gray-400"><span>Fdbk</span><span id="val-delay-feedback" class="text-sky-400">40%</span></div>
</div>
<div class="space-y-1">
<input type="range" id="delay-mix" min="0" max="1" step="0.01" value="0.3" class="w-full h-1 bg-gray-700 rounded-lg appearance-none accent-sky-500">
<div class="flex justify-between text-[9px] text-gray-400"><span>Mix</span><span id="val-delay-mix" class="text-sky-400">30%</span></div>
</div>
</div>
</div>
<!-- 6. Master -->
<div class="space-y-4 flex flex-col justify-between">
<div class="flex items-center gap-2 text-purple-400 border-b border-gray-700 pb-2 mb-1">
<i data-lucide="volume-2" width="14"></i>
<h3 class="font-bold text-[10px] uppercase tracking-wider">Out</h3>
</div>
<div class="flex flex-col items-center justify-center flex-grow">
<div class="relative group mb-2">
<input type="range" id="volume" min="0" max="0.5" step="0.01" value="0.5" class="w-16 h-16 -rotate-90 bg-transparent cursor-pointer appearance-none z-20 relative">
</div>
</div>
</div>
</div>
<!-- Keyboard Container -->
<div class="bg-gray-950 p-4 flex justify-center overflow-x-auto no-scrollbar">
<div id="keyboard" class="relative flex h-32 select-none">
<!-- Keys generated by JS -->
</div>
</div>
</div>
<script>
// --- CONSTANTS ---
const NOTES = [
{ note: 'C', freq: 261.63, key: 'a', type: 'white' },
{ note: 'C#', freq: 277.18, key: 'w', type: 'black' },
{ note: 'D', freq: 293.66, key: 's', type: 'white' },
{ note: 'D#', freq: 311.13, key: 'e', type: 'black' },
{ note: 'E', freq: 329.63, key: 'd', type: 'white' },
{ note: 'F', freq: 349.23, key: 'f', type: 'white' },
{ note: 'F#', freq: 369.99, key: 't', type: 'black' },
{ note: 'G', freq: 392.00, key: 'g', type: 'white' },
{ note: 'G#', freq: 415.30, key: 'y', type: 'black' },
{ note: 'A', freq: 440.00, key: 'h', type: 'white' },
{ note: 'A#', freq: 466.16, key: 'u', type: 'black' },
{ note: 'B', freq: 493.88, key: 'j', type: 'white' },
{ note: 'C2', freq: 523.25, key: 'k', type: 'white' },
];
// --- STATE ---
const state = {
waveform: 'sawtooth',
filterType: 'lowpass',
lfoTarget: 'filter',
attack: 0.01,
release: 0.5,
filterFreq: 1000,
filterQ: 5,
lfoRate: 5,
lfoDepth: 500,
delayTime: 0.3,
delayFeedback: 0.4,
delayMix: 0.3,
volume: 0.5
};
// --- AUDIO GLOBALS ---
let audioCtx = null;
let masterGain = null;
let filterNode = null;
let analyzer = null;
let lfoOsc = null;
let lfoGain = null;
let delayNode = null;
let feedbackGain = null;
let delayWetGain = null;
const activeOscillators = new Map();
const activeKeys = new Set();
// --- INITIALIZATION ---
const initAudio = () => {
if (audioCtx) return;
const AudioContext = window.AudioContext || window.webkitAudioContext;
audioCtx = new AudioContext();
// 1. Create Nodes
masterGain = audioCtx.createGain();
filterNode = audioCtx.createBiquadFilter();
analyzer = audioCtx.createAnalyser();
lfoOsc = audioCtx.createOscillator();
lfoGain = audioCtx.createGain();
delayNode = audioCtx.createDelay(5.0);
feedbackGain = audioCtx.createGain();
delayWetGain = audioCtx.createGain();
// 2. Set initial values
masterGain.gain.value = state.volume;
analyzer.fftSize = 2048;
filterNode.type = state.filterType;
filterNode.frequency.value = state.filterFreq;
filterNode.Q.value = state.filterQ;
lfoOsc.frequency.value = state.lfoRate;
lfoOsc.type = 'sine';
lfoOsc.start();
lfoGain.gain.value = state.lfoDepth;
delayNode.delayTime.value = state.delayTime;
feedbackGain.gain.value = state.delayFeedback;
delayWetGain.gain.value = state.delayMix;
// 3. Routing
// LFO -> LFO Gain
lfoOsc.connect(lfoGain);
// Filter -> Master -> Analyzer -> Speakers
filterNode.connect(masterGain);
masterGain.connect(analyzer);
analyzer.connect(audioCtx.destination);
// Delay Chain
filterNode.connect(delayNode);
delayNode.connect(delayWetGain);
delayWetGain.connect(masterGain);
// Feedback Loop
delayNode.connect(feedbackGain);
feedbackGain.connect(delayNode);
// Apply LFO Routing
updateLfoRouting();
// Hide Start Button
document.getElementById('start-btn').style.display = 'none';
// Start Visualizer
drawVisualizer();
};
const updateLfoRouting = () => {
try { lfoGain.disconnect(); } catch (e) {}
if (state.lfoTarget === 'filter') {
lfoGain.connect(filterNode.frequency);
} else if (state.lfoTarget === 'pitch') {
activeOscillators.forEach(voice => {
lfoGain.connect(voice.osc.detune);
});
}
};
// --- AUDIO ENGINE ---
const playNote = (freq) => {
if (!audioCtx) initAudio();
if (audioCtx.state === 'suspended') audioCtx.resume();
if (activeOscillators.has(freq)) return;
const now = audioCtx.currentTime;
const osc = audioCtx.createOscillator();
osc.type = state.waveform;
osc.frequency.setValueAtTime(freq, now);
const noteGain = audioCtx.createGain();
noteGain.gain.setValueAtTime(0, now);
noteGain.gain.linearRampToValueAtTime(1, now + state.attack);
osc.connect(noteGain);
noteGain.connect(filterNode);
if (state.lfoTarget === 'pitch') {
lfoGain.connect(osc.detune);
}
osc.start(now);
activeOscillators.set(freq, { osc, noteGain });
};
const stopNote = (freq) => {
const voice = activeOscillators.get(freq);
if (voice && audioCtx) {
const { osc, noteGain } = voice;
const now = audioCtx.currentTime;
noteGain.gain.cancelScheduledValues(now);
noteGain.gain.setValueAtTime(noteGain.gain.value, now);
noteGain.gain.exponentialRampToValueAtTime(0.001, now + state.release);
osc.stop(now + state.release + 0.1);
activeOscillators.delete(freq);
}
};
// --- VISUALIZER ---
const drawVisualizer = () => {
const canvas = document.getElementById('oscilloscope');
const canvasCtx = canvas.getContext('2d');
const bufferLength = analyzer.frequencyBinCount;
const dataArray = new Uint8Array(bufferLength);
const draw = () => {
requestAnimationFrame(draw);
analyzer.getByteTimeDomainData(dataArray);
canvasCtx.fillStyle = '#111827';
canvasCtx.fillRect(0, 0, canvas.width, canvas.height);
canvasCtx.lineWidth = 2;
canvasCtx.strokeStyle = '#38bdf8';
canvasCtx.beginPath();
const sliceWidth = canvas.width * 1.0 / bufferLength;
let x = 0;
for (let i = 0; i < bufferLength; i++) {
const v = dataArray[i] / 128.0;
const y = v * canvas.height / 2;
if (i === 0) canvasCtx.moveTo(x, y);
else canvasCtx.lineTo(x, y);
x += sliceWidth;
}
canvasCtx.lineTo(canvas.width, canvas.height / 2);
canvasCtx.stroke();
};
draw();
};
// --- UI GENERATION & EVENTS ---
// 1. Generate Keys
const keyboardEl = document.getElementById('keyboard');
let whiteKeyCount = 0;
NOTES.forEach((note, i) => {
const el = document.createElement('div');
el.dataset.note = note.note;
if (note.type === 'white') {
const offset = whiteKeyCount * (window.innerWidth < 768 ? 32 : 40);
el.className = `key-white absolute w-8 md:w-10 h-full bg-white border border-gray-300 rounded-b-md mx-[1px] z-10 cursor-pointer transition-colors duration-75`;
el.innerHTML = `<div class="absolute bottom-2 w-full text-center text-gray-400 font-mono text-[9px] pointer-events-none"><span class="block font-bold text-gray-600">${note.note}</span></div>`;
el.style.left = '0px'; // Flex container handles relative position if we didn't use absolute, but we are mimicking the React absolute logic
el.style.position = 'relative'; // Resetting for flex flow
keyboardEl.appendChild(el);
whiteKeyCount++;
} else {
// Black keys need absolute positioning relative to container
const prevWhiteCount = NOTES.slice(0, i).filter(n => n.type === 'white').length;
const leftPos = prevWhiteCount * (window.innerWidth < 768 ? 32 : 40) - (window.innerWidth < 768 ? 10 : 12); // Rough adjustment
el.className = `key-black absolute w-5 md:w-6 h-20 bg-gray-900 border border-gray-800 rounded-b-md z-20 cursor-pointer transition-transform duration-75`;
el.style.left = `${leftPos}px`;
keyboardEl.appendChild(el);
}
// Mouse Events
el.addEventListener('mousedown', () => {
highlightKey(el, true);
playNote(note.freq);
});
el.addEventListener('mouseup', () => {
highlightKey(el, false);
stopNote(note.freq);
});
el.addEventListener('mouseleave', () => {
highlightKey(el, false);
stopNote(note.freq);
});
});
const highlightKey = (el, isActive) => {
const isWhite = el.classList.contains('key-white');
if (isActive) {
if (isWhite) el.classList.add('bg-sky-100', '!border-sky-500', 'translate-y-1');
else el.classList.add('bg-gray-800', 'border-sky-500');
} else {
if (isWhite) el.classList.remove('bg-sky-100', '!border-sky-500', 'translate-y-1');
else el.classList.remove('bg-gray-800', 'border-sky-500');
}
};
// 2. Inputs and Sliders
const bindInput = (id, key, displayId, suffix = '') => {
const el = document.getElementById(id);
el.addEventListener('input', (e) => {
const val = parseFloat(e.target.value);
state[key] = val;
if(displayId) document.getElementById(displayId).innerText = (key.includes('mix') || key.includes('feedback')) ? Math.round(val * 100) + '%' : val + suffix;
// Apply Changes Realtime
if (!audioCtx) return;
const now = audioCtx.currentTime;
if (key === 'filterFreq') filterNode.frequency.setTargetAtTime(val, now, 0.1);
if (key === 'filterQ') filterNode.Q.setTargetAtTime(val, now, 0.1);
if (key === 'lfoRate') lfoOsc.frequency.setTargetAtTime(val, now, 0.1);
if (key === 'lfoDepth') lfoGain.gain.setTargetAtTime(val, now, 0.1);
if (key === 'delayTime') delayNode.delayTime.setTargetAtTime(val, now, 0.1);
if (key === 'delayFeedback') feedbackGain.gain.setTargetAtTime(val, now, 0.1);
if (key === 'delayMix') delayWetGain.gain.setTargetAtTime(val, now, 0.1);
if (key === 'volume') masterGain.gain.setTargetAtTime(val, now, 0.1);
});
};
bindInput('filter-freq', 'filterFreq', 'val-filter-freq', 'Hz');
bindInput('filter-q', 'filterQ', 'val-filter-q');
bindInput('env-attack', 'attack', 'val-env-attack', 's');
bindInput('env-release', 'release', 'val-env-release', 's');
bindInput('lfo-rate', 'lfoRate', 'val-lfo-rate', 'Hz');
bindInput('lfo-depth', 'lfoDepth', 'val-lfo-depth');
bindInput('delay-time', 'delayTime', 'val-delay-time', 's');
bindInput('delay-feedback', 'delayFeedback', 'val-delay-feedback');
bindInput('delay-mix', 'delayMix', 'val-delay-mix');
bindInput('volume', 'volume'); // No text display update needed mostly
// 3. Buttons (Waveform, Filter Type, LFO Target)
const bindButtons = (containerId, stateKey, btnClass, activeClass, normalClass) => {
const container = document.getElementById(containerId);
const buttons = container.querySelectorAll('button');
buttons.forEach(btn => {
btn.addEventListener('click', () => {
// Update UI
buttons.forEach(b => {
b.className = b.className.replace(activeClass, normalClass);
// Specific Logic for Tailwind Replacement messiness
if(stateKey === 'waveform') {
b.className = "wave-btn px-1 py-2 text-[9px] font-bold uppercase rounded border bg-gray-700 text-gray-300 border-gray-600 hover:bg-gray-600";
}
if(stateKey === 'filterType') {
b.className = "filter-btn flex-1 py-1 text-[9px] uppercase rounded bg-gray-700 text-gray-400";
}
if(stateKey === 'lfoTarget') {
b.className = "lfo-btn flex-1 py-1 text-[8px] uppercase rounded border bg-transparent text-gray-500 border-gray-700";
}
});
// Set Active Class
if(stateKey === 'waveform') {
btn.className = "wave-btn px-1 py-2 text-[9px] font-bold uppercase rounded border bg-green-500 text-gray-900 border-green-500 shadow-lg";
}
if(stateKey === 'filterType') {
btn.className = "filter-btn flex-1 py-1 text-[9px] uppercase rounded bg-yellow-500 text-black font-bold";
}
if(stateKey === 'lfoTarget') {
btn.className = "lfo-btn flex-1 py-1 text-[8px] uppercase rounded border bg-pink-500 text-black font-bold border-pink-500";
}
// Update State & Audio
const val = btn.dataset.wave || btn.dataset.type || btn.dataset.target;
state[stateKey] = val;
if(!audioCtx) return;
if(stateKey === 'filterType') filterNode.type = val;
if(stateKey === 'lfoTarget') updateLfoRouting();
});
});
};
bindButtons('waveform-buttons', 'waveform');
bindButtons('filter-type-buttons', 'filterType');
bindButtons('lfo-target-buttons', 'lfoTarget');
document.getElementById('start-btn').addEventListener('click', initAudio);
// 4. Keyboard Support
window.addEventListener('keydown', (e) => {
if(e.repeat) return;
const noteData = NOTES.find(n => n.key === e.key.toLowerCase());
if (noteData) {
const els = document.querySelectorAll('#keyboard > div');
const el = Array.from(els).find(div => div.dataset.note === noteData.note);
if(el) highlightKey(el, true);
playNote(noteData.freq);
}
});
window.addEventListener('keyup', (e) => {
const noteData = NOTES.find(n => n.key === e.key.toLowerCase());
if (noteData) {
const els = document.querySelectorAll('#keyboard > div');
const el = Array.from(els).find(div => div.dataset.note === noteData.note);
if(el) highlightKey(el, false);
stopNote(noteData.freq);
}
});
// Init Icons
lucide.createIcons();
</script>
</body>
</html>
From <https://gemini.google.com/app/6eff5da4708388e3> Google Gemini 3.0 Pro (Think)

Comments
Post a Comment