import React, { useState, useEffect, useRef, useCallback } from 'react'; import { Volume2, Music, Activity, Settings, Play, Square, Triangle, Disc, Zap } from 'lucide-react'; // --- Constants & Utilities --- const NOTES = [ { note: 'C', freq: 261.63, key: 'a' }, { note: 'C#', freq: 277.18, key: 'w', black: true }, { note: 'D', freq: 293.66, key: 's' }, { note: 'D#', freq: 311.13, key: 'e', black: true }, { note: 'E', freq: 329.63, key: 'd' }, { note: 'F', freq: 349.23, key: 'f' }, { note: 'F#', freq: 369.99, key: 't', black: true }, { note: 'G', freq: 392.00, key: 'g' }, { note: 'G#', freq: 415.30, key: 'y', black: true }, { note: 'A', freq: 440.00, key: 'h' }, { note: 'A#', freq: 466.16, key: 'u', black: true }, { note: 'B', freq: 493.88, key: 'j' }, { note: 'C2', freq: 523.25, key: 'k' }, ]; const WAVEFORMS = [ { id: 'sine', label: 'Sine', icon: Disc }, { id: 'square', label: 'Square', icon: Square }, { id: 'sawtooth', label: 'Sawtooth', icon: Zap }, { id: 'triangle', label: 'Triangle', icon: Triangle }, ]; // --- Custom Hook: useAudioEngine --- // This fixes the previous error by ensuring hooks (useRef, useEffect) are used correctly inside a custom hook. const useAudioEngine = () => { const audioCtxRef = useRef(null); const masterGainRef = useRef(null); const analyzerRef = useRef(null); const oscillatorsRef = useRef(new Map()); // Map const [isInitialized, setIsInitialized] = useState(false); // Audio Params State const [volume, setVolume] = useState(0.5); const [waveform, setWaveform] = useState('sine'); const [attack, setAttack] = useState(0.01); const [release, setRelease] = useState(0.3); const [filterCutoff, setFilterCutoff] = useState(2000); const initAudio = useCallback(() => { if (audioCtxRef.current) return; const AudioContext = window.AudioContext || window.webkitAudioContext; const ctx = new AudioContext(); const masterGain = ctx.createGain(); const analyzer = ctx.createAnalyser(); masterGain.connect(analyzer); analyzer.connect(ctx.destination); // Initial settings masterGain.gain.value = volume; analyzer.fftSize = 2048; audioCtxRef.current = ctx; masterGainRef.current = masterGain; analyzerRef.current = analyzer; setIsInitialized(true); }, [volume]); // Update volume when state changes useEffect(() => { if (masterGainRef.current) { masterGainRef.current.gain.setTargetAtTime(volume, audioCtxRef.current.currentTime, 0.01); } }, [volume]); const playNote = useCallback((freq) => { if (!audioCtxRef.current) initAudio(); if (audioCtxRef.current.state === 'suspended') audioCtxRef.current.resume(); // Prevent duplicate oscillators for the same note if (oscillatorsRef.current.has(freq)) return; const ctx = audioCtxRef.current; const osc = ctx.createOscillator(); const noteGain = ctx.createGain(); const filter = ctx.createBiquadFilter(); osc.type = waveform; osc.frequency.setValueAtTime(freq, ctx.currentTime); // Filter settings filter.type = 'lowpass'; filter.frequency.setValueAtTime(filterCutoff, ctx.currentTime); // Envelope: Attack noteGain.gain.setValueAtTime(0, ctx.currentTime); noteGain.gain.linearRampToValueAtTime(1, ctx.currentTime + attack); // Connections: Osc -> Filter -> NoteGain -> MasterGain osc.connect(filter); filter.connect(noteGain); noteGain.connect(masterGainRef.current); osc.start(); oscillatorsRef.current.set(freq, { osc, noteGain, filter }); }, [waveform, attack, filterCutoff, initAudio]); const stopNote = useCallback((freq) => { if (!audioCtxRef.current) return; const activeNote = oscillatorsRef.current.get(freq); if (activeNote) { const { osc, noteGain } = activeNote; const ctx = audioCtxRef.current; // Envelope: Release // Cancel any scheduled updates to avoid conflict with release noteGain.gain.cancelScheduledValues(ctx.currentTime); noteGain.gain.setValueAtTime(noteGain.gain.value, ctx.currentTime); noteGain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + release); osc.stop(ctx.currentTime + release + 0.1); // Cleanup map after release setTimeout(() => { if (oscillatorsRef.current.get(freq) === activeNote) { oscillatorsRef.current.delete(freq); } }, (release * 1000) + 100); } }, [release]); return { isInitialized, initAudio, playNote, stopNote, analyzerRef, // Expose analyzer for visualizer settings: { volume, setVolume, waveform, setWaveform, attack, setAttack, release, setRelease, filterCutoff, setFilterCutoff } }; }; // --- Components --- const Knob = ({ label, value, min, max, onChange, unit = '' }) => { // Simple knob implementation using a range input styled vertically or circular return (
1000 ? 100 : 0.01} value={value} onChange={(e) => onChange(parseFloat(e.target.value))} className="absolute w-full h-full opacity-0 cursor-pointer" title={`${label}: ${value}${unit}`} /> {/* Visual Indicator */}
{label} {value}{unit}
); }; const Visualizer = ({ analyzerRef, isInitialized }) => { const canvasRef = useRef(null); useEffect(() => { if (!isInitialized || !analyzerRef.current || !canvasRef.current) return; const canvas = canvasRef.current; const ctx = canvas.getContext('2d'); const analyzer = analyzerRef.current; const bufferLength = analyzer.frequencyBinCount; const dataArray = new Uint8Array(bufferLength); let animationId; const draw = () => { animationId = requestAnimationFrame(draw); // Handle resize safely if (canvas.width !== canvas.clientWidth || canvas.height !== canvas.clientHeight) { canvas.width = canvas.clientWidth; canvas.height = canvas.clientHeight; } analyzer.getByteFrequencyData(dataArray); ctx.fillStyle = 'rgb(17, 24, 39)'; // Tailwind gray-900 ctx.fillRect(0, 0, canvas.width, canvas.height); const barWidth = (canvas.width / bufferLength) * 2.5; let barHeight; let x = 0; for (let i = 0; i < bufferLength; i++) { barHeight = dataArray[i] / 2; // Scale down const gradient = ctx.createLinearGradient(0, canvas.height - barHeight, 0, canvas.height); gradient.addColorStop(0, '#60a5fa'); // blue-400 gradient.addColorStop(1, '#3b82f6'); // blue-500 ctx.fillStyle = gradient; ctx.fillRect(x, canvas.height - barHeight, barWidth, barHeight); x += barWidth + 1; } }; draw(); return () => cancelAnimationFrame(animationId); }, [analyzerRef, isInitialized]); return ( ); }; const Keyboard = ({ playNote, stopNote }) => { const [activeKeys, setActiveKeys] = useState(new Set()); const handleMouseDown = (freq) => { playNote(freq); setActiveKeys(prev => new Set(prev).add(freq)); }; const handleMouseUp = (freq) => { stopNote(freq); setActiveKeys(prev => { const next = new Set(prev); next.delete(freq); return next; }); }; // Keyboard mapping useEffect(() => { const handleKeyDown = (e) => { if (e.repeat) return; const note = NOTES.find(n => n.key === e.key.toLowerCase()); if (note) handleMouseDown(note.freq); }; const handleKeyUp = (e) => { const note = NOTES.find(n => n.key === e.key.toLowerCase()); if (note) handleMouseUp(note.freq); }; window.addEventListener('keydown', handleKeyDown); window.addEventListener('keyup', handleKeyUp); return () => { window.removeEventListener('keydown', handleKeyDown); window.removeEventListener('keyup', handleKeyUp); }; }, [playNote, stopNote]); // Depend on memoized functions return (
{/* White Keys Layer */}
{NOTES.filter(n => !n.black).map((note) => { const isActive = activeKeys.has(note.freq); return (
handleMouseDown(note.freq)} onMouseUp={() => handleMouseUp(note.freq)} onMouseLeave={() => handleMouseUp(note.freq)} className={` relative border border-gray-400 rounded-b-lg mx-0.5 cursor-pointer transition-all duration-75 w-16 h-full flex items-end justify-center pb-4 active:scale-y-[0.98] origin-top ${isActive ? 'bg-blue-100 shadow-inner' : 'bg-white hover:bg-gray-50'} `} > {note.key.toUpperCase()}
); })}
{/* Black Keys Layer (Absolute positioning) */}
{/* Spacer logic to align black keys. This is a simplified visual hack. A real grid would be better, but this works for fixed notes. Since we centered the white keys, we need to carefully position these. */}
{/* Manually spaced groups based on standard piano layout */}
{/* Initial offset C */} {/* C# */}
{/* D Spacer */} {/* D# */}
{/* E, F Spacer */} {/* F# */}
{/* G Spacer */} {/* G# */}
{/* A Spacer */} {/* A# */}
{/* B, C2 Spacer */}
); }; const BlackKey = ({ note, activeKeys, onDown, onUp }) => { const isActive = activeKeys.has(note.freq); return (
{ e.stopPropagation(); onDown(note.freq); }} onMouseUp={(e) => { e.stopPropagation(); onUp(note.freq); }} onMouseLeave={(e) => { e.stopPropagation(); onUp(note.freq); }} className={` w-10 h-full rounded-b-md z-10 cursor-pointer transition-transform duration-75 border border-gray-900 mx-[0.8rem] active:scale-y-[0.95] origin-top shadow-lg ${isActive ? 'bg-blue-800' : 'bg-gray-800 hover:bg-gray-700'} `} > {/* Key Label */}
{note.key.toUpperCase()}
); } // --- Main App Component --- const App = () => { const { isInitialized, initAudio, playNote, stopNote, analyzerRef, settings } = useAudioEngine(); // If user hasn't interacted, show start screen (browsers block auto-audio) if (!isInitialized) { return (

React Synth

A real-time synthesizer built with React and the Web Audio API. Features responsive envelopes, filtering, and visual analysis.

); } return (
{/* Header */}
React Synth
Status: Active
v1.2.0
{/* Top Section: Visualizer & Global Controls */}
{/* Visualizer Panel */}

Frequency Analysis

{/* Global Volume & Info */}

Master Output

settings.setVolume(v / 100)} unit="%" />
Use your keyboard (A-K) or click the piano keys below to play.
{/* Controls Section */}
{/* Waveform Selector */}

Oscillator

{WAVEFORMS.map((wf) => { const Icon = wf.icon; const isSelected = settings.waveform === wf.id; return ( ); })}
{/* Envelope Controls */}

Envelope (ADSR)

{}} unit="s" /> {/* Decay/Sustain are hardcoded in engine for simplicity of this demo, but UI shows placeholder to look complete */}
{/* Filter Controls */}

Filter

{/* Keyboard Section */}
); }; export default App;