import { useState, useRef, useEffect, useCallback } from 'react'; import { INSTRUMENTS, INITIAL_TEMPO, INITIAL_STEPS, MIN_STEPS, MAX_STEPS, NOTE_FREQ_MAP } from '../constants'; import { Grid, BeatData, BassLineGrid } from '../types'; const createEmptyGrid = (steps: number): Grid => { return Array.from({ length: INSTRUMENTS.length }, () => Array(steps).fill(false)); }; const createEmptyBassLine = (steps: number): BassLineGrid => { return Array.from({ length: steps }, () => []); } const playKick = (audioContext: AudioContext, time: number, destination: AudioNode) => { const osc = audioContext.createOscillator(); const subOsc = audioContext.createOscillator(); const gain = audioContext.createGain(); const subGain = audioContext.createGain(); const shaper = audioContext.createWaveShaper(); shaper.curve = new Float32Array(65536).map((_, i) => { const x = (i / 32768) - 1; return Math.tanh(3 * x); }); shaper.oversample = '4x'; osc.connect(gain); subOsc.connect(subGain); gain.connect(shaper); subGain.connect(shaper); shaper.connect(destination); const duration = 0.6; const finalStopTime = time + duration; osc.type = 'sine'; osc.frequency.setValueAtTime(180, time); osc.frequency.exponentialRampToValueAtTime(30, time + 0.1); gain.gain.setValueAtTime(1.8, time); gain.gain.setTargetAtTime(0, time, 0.1); subOsc.type = 'sine'; subOsc.frequency.setValueAtTime(50, time); subGain.gain.setValueAtTime(0.35, time); subGain.gain.exponentialRampToValueAtTime(0.001, time + 0.2); osc.start(time); subOsc.start(time); osc.stop(finalStopTime); subOsc.stop(finalStopTime); }; const playSnare = (audioContext: AudioContext, time: number, destination: AudioNode) => { const noiseGain = audioContext.createGain(); const noiseFilter = audioContext.createBiquadFilter(); const osc = audioContext.createOscillator(); const oscGain = audioContext.createGain(); const noiseBuffer = audioContext.createBuffer(1, audioContext.sampleRate * 0.5, audioContext.sampleRate); const output = noiseBuffer.getChannelData(0); for (let i = 0; i < output.length; i++) { output[i] = Math.random() * 2 - 1; } const noiseSource = audioContext.createBufferSource(); noiseSource.buffer = noiseBuffer; noiseFilter.type = 'highpass'; noiseFilter.frequency.value = 1000; noiseSource.connect(noiseFilter); noiseFilter.connect(noiseGain); noiseGain.connect(destination); osc.type = 'triangle'; osc.frequency.setValueAtTime(200, time); osc.connect(oscGain); oscGain.connect(destination); const duration = 0.2; noiseGain.gain.setValueAtTime(1, time); noiseGain.gain.exponentialRampToValueAtTime(0.01, time + duration); oscGain.gain.setValueAtTime(0.7, time); oscGain.gain.exponentialRampToValueAtTime(0.01, time + duration / 2); noiseSource.start(time); osc.start(time); noiseSource.stop(time + duration); osc.stop(time + duration); }; const createHiHatSound = (audioContext: AudioContext, time: number, destination: AudioNode, duration: number) => { const fundamental = 40; const ratios = [2, 3, 4.16, 5.43, 6.79, 8.21]; const gain = audioContext.createGain(); const bandpass = audioContext.createBiquadFilter(); const highpass = audioContext.createBiquadFilter(); bandpass.type = 'bandpass'; bandpass.frequency.value = 10000; bandpass.Q.value = 0.5; highpass.type = 'highpass'; highpass.frequency.value = 7000; gain.connect(bandpass); bandpass.connect(highpass); highpass.connect(destination); ratios.forEach(ratio => { const osc = audioContext.createOscillator(); osc.type = 'square'; osc.frequency.value = (fundamental * ratio) + (Math.random() * fundamental * 0.1); osc.connect(gain); osc.start(time); osc.stop(time + duration); }); gain.gain.setValueAtTime(0.00001, time); gain.gain.exponentialRampToValueAtTime(0.4, time + 0.02); gain.gain.exponentialRampToValueAtTime(0.00001, time + duration); }; const playHiHat = (audioContext: AudioContext, time: number, destination: AudioNode) => { createHiHatSound(audioContext, time, destination, 0.08); }; const playOpenHat = (audioContext: AudioContext, time: number, destination: AudioNode) => { createHiHatSound(audioContext, time, destination, 0.8); }; const playRide = (audioContext: AudioContext, time: number, destination: AudioNode) => { const masterGain = audioContext.createGain(); const highpass = audioContext.createBiquadFilter(); highpass.type = 'highpass'; highpass.frequency.setValueAtTime(800, time); highpass.Q.value = 0.8; masterGain.connect(highpass); highpass.connect(destination); const tickOsc = audioContext.createOscillator(); const tickGain = audioContext.createGain(); tickOsc.type = 'square'; tickOsc.frequency.setValueAtTime(1200, time); tickGain.gain.setValueAtTime(0.5, time); tickGain.gain.exponentialRampToValueAtTime(0.0001, time + 0.02); tickOsc.connect(tickGain); tickGain.connect(masterGain); const fundamental = 120; const ratios = [1.00, 1.41, 2.23, 2.77, 3.14, 4.01]; ratios.forEach(ratio => { const osc = audioContext.createOscillator(); osc.type = 'square'; osc.frequency.value = fundamental * ratio + (Math.random() - 0.5) * 5; osc.connect(masterGain); osc.start(time); osc.stop(time + 1.2); }); masterGain.gain.setValueAtTime(0.0001, time); masterGain.gain.exponentialRampToValueAtTime(0.6, time + 0.005); masterGain.gain.exponentialRampToValueAtTime(0.2, time + 0.1); masterGain.gain.exponentialRampToValueAtTime(0.0001, time + 1.2); tickOsc.start(time); tickOsc.stop(time + 0.03); }; export const useDrumMachine = (lastMessage: any, sendMessage: (message: any) => void) => { const [steps, setSteps] = useState(INITIAL_STEPS); const [grid, setGrid] = useState(createEmptyGrid(INITIAL_STEPS)); const [bassLine, setBassLine] = useState(() => createEmptyBassLine(INITIAL_STEPS)); const [isPlaying, setIsPlaying] = useState(false); const [tempo, setTempo] = useState(INITIAL_TEMPO); const [currentStep, setCurrentStep] = useState(null); const [mutes, setMutes] = useState(() => Array(INSTRUMENTS.length).fill(false)); const [drumVolume, setDrumVolume] = useState(1); const [bassVolume, setBassVolume] = useState(0.4); const audioContextRef = useRef(null); const audioBuffersRef = useRef>(new Map()); const timerRef = useRef(null); const lookahead = 25.0; const scheduleAheadTime = 0.1; const nextNoteTimeRef = useRef(0.0); const sequenceStepRef = useRef(0); const activeOscillatorsRef = useRef>(new Map()); const drumMasterGainRef = useRef(null); const bassMasterGainRef = useRef(null); const loadSamples = useCallback(async (context: AudioContext) => { const newBuffers = new Map(); for (const instrument of INSTRUMENTS) { if (!instrument.sampleUrl) continue; try { const response = await fetch(instrument.sampleUrl); const arrayBuffer = await response.arrayBuffer(); const decodedData = await context.decodeAudioData(arrayBuffer); newBuffers.set(instrument.name, decodedData); } catch (error) { console.error(`Error loading sample for ${instrument.name}:`, error); } } audioBuffersRef.current = newBuffers; }, []); const initAudio = useCallback(async () => { if (!audioContextRef.current) { audioContextRef.current = new window.AudioContext(); await loadSamples(audioContextRef.current); drumMasterGainRef.current = audioContextRef.current.createGain(); drumMasterGainRef.current.gain.value = drumVolume; drumMasterGainRef.current.connect(audioContextRef.current.destination); bassMasterGainRef.current = audioContextRef.current.createGain(); bassMasterGainRef.current.gain.value = bassVolume; bassMasterGainRef.current.connect(audioContextRef.current.destination); } }, [loadSamples, drumVolume, bassVolume]); const stopAllBassNotes = useCallback(() => { if (!audioContextRef.current || !bassMasterGainRef.current) return; const now = audioContextRef.current.currentTime; activeOscillatorsRef.current.forEach(({ osc, gain }) => { try { gain.gain.cancelScheduledValues(now); gain.gain.setTargetAtTime(0, now, 0.01); osc.stop(now + 0.1); } catch (e) { /* Ignore errors */ } }); activeOscillatorsRef.current.clear(); }, []); const setStep = (instrumentIndex: number, stepIndex: number, isActive: boolean) => { const newGrid = grid.map(row => [...row]); if(newGrid[instrumentIndex]){ newGrid[instrumentIndex][stepIndex] = isActive; } setGrid(newGrid); sendMessage({ type: 'grid', payload: { grid: newGrid } }); }; const setBassNote = (stepIndex: number, note: string) => { const newBassLine = bassLine.map(stepNotes => [...stepNotes]); const stepNotes = newBassLine[stepIndex]; const noteIndex = stepNotes.indexOf(note); if (noteIndex > -1) { stepNotes.splice(noteIndex, 1); } else { stepNotes.push(note); } setBassLine(newBassLine); sendMessage({ type: 'bassLine', payload: { bassLine: newBassLine } }); }; const clearPattern = () => { if (timerRef.current) { window.clearTimeout(timerRef.current); timerRef.current = null; } const newGrid = createEmptyGrid(steps); const newBassLine = createEmptyBassLine(steps); setGrid(newGrid); setBassLine(newBassLine); stopAllBassNotes(); sendMessage({ type: 'clear', payload: { steps } }); }; const toggleMute = (instrumentIndex: number) => { const newMutes = [...mutes]; newMutes[instrumentIndex] = !newMutes[instrumentIndex]; setMutes(newMutes); sendMessage({ type: 'mutes', payload: { mutes: newMutes } }); }; const resizeGrid = useCallback((newSteps: number) => { setGrid(prevGrid => { const newGrid = createEmptyGrid(newSteps); for(let i=0; i { const newBassLine = createEmptyBassLine(newSteps); for (let i = 0; i < Math.min(prevBassLine.length, newSteps); i++) { newBassLine[i] = prevBassLine[i]; } return newBassLine; }); }, []); const handleStepsChange = (newSteps: number) => { const clampedSteps = Math.max(MIN_STEPS, Math.min(MAX_STEPS, newSteps)); if (clampedSteps === steps) return; setSteps(clampedSteps); resizeGrid(clampedSteps); sendMessage({ type: 'steps', payload: { steps: clampedSteps } }); }; const scheduleNote = useCallback((beatNumber: number, time: number) => { const audioContext = audioContextRef.current; if (!audioContext || !bassMasterGainRef.current || !drumMasterGainRef.current) return; for (let i = 0; i < INSTRUMENTS.length; i++) { const instrumentName = INSTRUMENTS[i].name; if (grid[i]?.[beatNumber] && !mutes[i]) { if (instrumentName === 'Kick') playKick(audioContext, time, drumMasterGainRef.current); else if (instrumentName === 'Snare') playSnare(audioContext, time, drumMasterGainRef.current); else if (instrumentName === 'Hi-Hat') playHiHat(audioContext, time, drumMasterGainRef.current); else if (instrumentName === 'Open Hat') playOpenHat(audioContext, time, drumMasterGainRef.current); else if (instrumentName === 'Ride') playRide(audioContext, time, drumMasterGainRef.current); } } const previousBeatNumber = (beatNumber - 1 + steps) % steps; const currentNotes = bassLine[beatNumber] || []; const previousNotes = bassLine[previousBeatNumber] || []; previousNotes.forEach(note => { if (!currentNotes.includes(note)) { const activeNode = activeOscillatorsRef.current.get(note); if (activeNode) { const { osc, gain } = activeNode; gain.gain.cancelScheduledValues(time); gain.gain.setValueAtTime(gain.gain.value, time); gain.gain.linearRampToValueAtTime(0, time + 0.02); osc.stop(time + 0.02); activeOscillatorsRef.current.delete(note); } } }); currentNotes.forEach(note => { if (!previousNotes.includes(note)) { const freq = NOTE_FREQ_MAP[note]; if (freq) { const oscillator = audioContext.createOscillator(); const gainNode = audioContext.createGain(); oscillator.type = 'sine'; oscillator.frequency.setValueAtTime(freq, time); gainNode.connect(bassMasterGainRef.current as GainNode); gainNode.gain.setValueAtTime(0, time); gainNode.gain.linearRampToValueAtTime(0.3, time + 0.01); oscillator.connect(gainNode); oscillator.start(time); activeOscillatorsRef.current.set(note, { osc: oscillator, gain: gainNode }); } } }); }, [grid, mutes, bassLine, steps]); const scheduler = useCallback(() => { const audioContext = audioContextRef.current; if (!audioContext) return; while (nextNoteTimeRef.current < audioContext.currentTime + scheduleAheadTime) { scheduleNote(sequenceStepRef.current, nextNoteTimeRef.current); setCurrentStep(sequenceStepRef.current); const secondsPerBeat = 60.0 / tempo; const secondsPerStep = secondsPerBeat / 4; nextNoteTimeRef.current += secondsPerStep; sequenceStepRef.current = (sequenceStepRef.current + 1) % steps; } timerRef.current = window.setTimeout(scheduler, lookahead); }, [tempo, steps, scheduleNote]); const startPlayback = async () => { await initAudio(); const audioContext = audioContextRef.current; if (!audioContext || !bassMasterGainRef.current) return; if (audioContext.state === 'suspended') await audioContext.resume(); const now = audioContext.currentTime; bassMasterGainRef.current.gain.cancelScheduledValues(now); bassMasterGainRef.current.gain.setTargetAtTime(bassVolume, now, 0.01); sequenceStepRef.current = 0; setCurrentStep(null); nextNoteTimeRef.current = now; setIsPlaying(true); sendMessage({ type: 'playback', payload: { isPlaying: true } }); }; const stopPlayback = () => { setIsPlaying(false); sendMessage({ type: 'playback', payload: { isPlaying: false } }); }; const exportBeat = () => { const beatData: BeatData = { tempo, steps, grid, mutes, bassLine }; const jsonString = JSON.stringify(beatData, null, 2); const blob = new Blob([jsonString], { type: 'application/json' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = 'my-beat.json'; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); }; const importBeat = (event: React.ChangeEvent) => { const file = event.target.files?.[0]; if (!file) return; const reader = new FileReader(); reader.onload = (e) => { try { const text = e.target?.result; if (typeof text !== 'string') throw new Error("File is not valid text"); const data: BeatData = JSON.parse(text); if(typeof data.tempo !== 'number' || typeof data.steps !== 'number' || !Array.isArray(data.grid)) { throw new Error("Invalid beat file format."); } setTempo(data.tempo); setSteps(data.steps); setGrid(data.grid); setMutes(data.mutes || Array(INSTRUMENTS.length).fill(false)); setBassLine(data.bassLine || createEmptyBassLine(data.steps)); sendMessage({ type: 'import', payload: data }); alert('Beat imported successfully!'); } catch (error) { console.error("Failed to import beat:", error); alert('Failed to import beat. The file may be invalid.'); } }; reader.readAsText(file); event.target.value = ''; }; const handleDrumVolumeChange = useCallback((newVolume: number) => { setDrumVolume(newVolume); if (drumMasterGainRef.current && audioContextRef.current) { drumMasterGainRef.current.gain.setTargetAtTime(newVolume, audioContextRef.current.currentTime, 0.01); } sendMessage({ type: 'drumVolume', payload: { drumVolume: newVolume } }); }, [sendMessage]); const handleBassVolumeChange = useCallback((newVolume: number) => { setBassVolume(newVolume); if (bassMasterGainRef.current && audioContextRef.current) { bassMasterGainRef.current.gain.setTargetAtTime(newVolume, audioContextRef.current.currentTime, 0.01); } sendMessage({ type: 'bassVolume', payload: { bassVolume: newVolume } }); }, [sendMessage]); const triggerSound = useCallback(async (instrumentName: string) => { await initAudio(); const audioContext = audioContextRef.current; if (!audioContext || !drumMasterGainRef.current) return; const time = audioContext.currentTime; if (instrumentName === 'Kick') playKick(audioContext, time, drumMasterGainRef.current); else if (instrumentName === 'Snare') playSnare(audioContext, time, drumMasterGainRef.current); else if (instrumentName === 'Hi-Hat') playHiHat(audioContext, time, drumMasterGainRef.current); else if (instrumentName === 'Open Hat') playOpenHat(audioContext, time, drumMasterGainRef.current); else if (instrumentName === 'Ride') playRide(audioContext, time, drumMasterGainRef.current); }, [initAudio]); const triggerBassNote = useCallback(async (note: string) => { await initAudio(); const audioContext = audioContextRef.current; if (!audioContext || !bassMasterGainRef.current) return; const time = audioContext.currentTime; bassMasterGainRef.current.gain.cancelScheduledValues(time); bassMasterGainRef.current.gain.setTargetAtTime(bassVolume, time, 0.01); const freq = NOTE_FREQ_MAP[note]; if (freq) { const oscillator = audioContext.createOscillator(); const gainNode = audioContext.createGain(); oscillator.type = 'sine'; oscillator.frequency.setValueAtTime(freq, time); gainNode.connect(bassMasterGainRef.current as GainNode); gainNode.gain.setValueAtTime(0, time); gainNode.gain.linearRampToValueAtTime(0.3, time + 0.01); gainNode.gain.linearRampToValueAtTime(0, time + 1.0); oscillator.connect(gainNode); oscillator.start(time); oscillator.stop(time + 1.0); } }, [initAudio, bassVolume]); const setTempoCallback = useCallback((value: React.SetStateAction) => { setTempo(prevTempo => { const newTempo = typeof value === 'function' ? value(prevTempo) : value; sendMessage({ type: 'tempo', payload: { tempo: newTempo } }); return newTempo; }); }, [sendMessage]); useEffect(() => { if (isPlaying) { if (timerRef.current) clearTimeout(timerRef.current); scheduler(); } else { if (timerRef.current) clearTimeout(timerRef.current); stopAllBassNotes(); setCurrentStep(null); } return () => { if (timerRef.current) window.clearTimeout(timerRef.current); }; }, [isPlaying, scheduler, stopAllBassNotes]); useEffect(() => { if (lastMessage) { const { type, payload } = lastMessage; switch (type) { case 'state': if (payload.grid) setGrid(payload.grid); if (payload.bassLine) setBassLine(payload.bassLine); if (payload.tempo) setTempo(payload.tempo); if (payload.isPlaying) setIsPlaying(payload.isPlaying); if (payload.mutes) setMutes(payload.mutes); if (payload.drumVolume) setDrumVolume(payload.drumVolume); if (payload.bassVolume) setBassVolume(payload.bassVolume); if (payload.steps) { setSteps(payload.steps); resizeGrid(payload.steps); } break; case 'grid': setGrid(payload.grid); break; case 'bassLine': setBassLine(payload.bassLine); break; case 'tempo': setTempo(payload.tempo); break; case 'playback': setIsPlaying(payload.isPlaying); break; case 'mutes': setMutes(payload.mutes); break; case 'drumVolume': setDrumVolume(payload.drumVolume); break; case 'bassVolume': setBassVolume(payload.bassVolume); break; case 'steps': setSteps(payload.steps); resizeGrid(payload.steps); break; case 'clear': setGrid(createEmptyGrid(payload.steps)); setBassLine(createEmptyBassLine(payload.steps)); break; case 'import': setTempo(payload.tempo); setSteps(payload.steps); setGrid(payload.grid); setMutes(payload.mutes); setBassLine(payload.bassLine); break; } } }, [lastMessage, resizeGrid]); useEffect(() => { return () => { stopAllBassNotes(); if (audioContextRef.current?.state !== 'closed') { audioContextRef.current?.close(); } }; }, [stopAllBassNotes]); return { steps, grid, bassLine, isPlaying, tempo, currentStep, mutes, drumVolume, bassVolume, setStep, setBassNote, clearPattern, setTempo: setTempoCallback, handleStepsChange, startPlayback, stopPlayback, exportBeat, importBeat, toggleMute, handleDrumVolumeChange, handleBassVolumeChange, triggerSound, triggerBassNote, }; };