#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:

  1. Polyphony: The ability to play multiple notes at once.

  2. Oscillators: Generating the raw sound (Sine, Square, Sawtooth, Triangle).

  3. Envelope (ADSR): Controlling how the sound starts and stops (Attack and Release) to prevent "popping" noises.

  4. 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:

  1. Polyphonic Sound: You can play chords (e.g., C, E, and G together).

  2. Multiple Waveforms: You can switch between Sawtooth (buzzy), Square (video game style), Triangle (flute-like), and Sine (pure smooth tone).

  3. Basic ADSR Envelope:

    • Attack: How fast the sound fades in.

    • Release: How long the sound lingers after you let go of the key.

  4. Real-time Visualization: An oscilloscope that draws the actual sound wave being generated by the browser.

How to use it:

  1. Click "START ENGINE" (Browsers require a user interaction to start audio).

  2. Use your mouse to click the piano keys.

  3. Or, use your keyboard!

    • A = C

    • W = 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:

  1. Filter Node: A BiquadFilterNode inserted 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: OscillatorEnvelope GainFilterMaster VolumeSpeakers

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:

  1. LFO Rate: How fast it wobbles.

  2. LFO Depth: How intense the wobble is.

  3. 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:

  1. Dry Path: Goes straight to the speakers (so you hear the note immediately).

  2. 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 X seconds.

  • 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.

Result: A harsh, buzzy, instant "beep". This is your clay.

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 tremolo in 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:

  1. Source: Sawtooth.

  2. Filter: LP, Freq ~2000Hz, Res ~5.

  3. ADSR: Attack 0.1s (slight fade in), Release 0.8s (long tail).

  4. LFO: Target Pitch, Rate 3Hz, Depth 20 (Subtle detune).

  5. Delay: Time 0.4s, Feedback 0.5, Mix 0.4.

Play high notes on the keyboard with this setting!



How can this current app be made stand-alone (HTML, JavaScript, CSS)?

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:

  1. React Hooks (useState, useRef) $\rightarrow$ Global JavaScript variables.

  2. JSX (HTML in JS) $\rightarrow$ Standard HTML structure.

  3. Tailwind CSS $\rightarrow$ Imported via a CDN link.

  4. Lucide React Icons $\rightarrow$ Imported via a CDN script.

  5. Event Handlers (onClick, onChange) $\rightarrow$ addEventListener or 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

Popular posts from this blog

#2892 Example of ChatGPT o1 Prompt

#3105 Federal and State Spending Idiocy