import { useEffect, useRef, useState, useCallback } from 'react' import { useStore } from '@nanostores/react' import { LFO, type LFOWaveform } from '../domain/modulation/LFO' import { mappingMode } from '../stores/mappingMode' import { parameterRegistry } from '../domain/modulation/ParameterRegistry' import { MappingEditor } from './MappingEditor' interface LFOScopeProps { lfoIndex: number waveform: LFOWaveform frequency: number phase: number mappings: Array<{ targetParam: string; depth: number }> onChange: (frequency: number, phase: number, waveform: LFOWaveform) => void onMapClick: () => void onUpdateDepth: (paramId: string, depth: number) => void onRemoveMapping: (paramId: string) => void } const WAVEFORMS: LFOWaveform[] = ['sine', 'triangle', 'square', 'sawtooth', 'random'] const CANVAS_WIDTH = 340 const CANVAS_HEIGHT = 60 const MIN_FREQ = 0.01 const MAX_FREQ = 20 export function LFOScope({ lfoIndex, waveform, frequency, phase, mappings, onChange, onMapClick, onUpdateDepth, onRemoveMapping }: LFOScopeProps) { const canvasRef = useRef(null) const lfoRef = useRef(null) const animationRef = useRef(null) const [isDragging, setIsDragging] = useState(false) const [showMappings, setShowMappings] = useState(false) const [showEditor, setShowEditor] = useState(false) const dragStartRef = useRef<{ x: number; y: number; freq: number; phase: number; moved: boolean } | null>(null) const mappingModeState = useStore(mappingMode) const getLFOValueAtPhase = useCallback((phaseVal: number): number => { const normalizedPhase = phaseVal % 1 switch (waveform) { case 'sine': return Math.sin(normalizedPhase * 2 * Math.PI) case 'triangle': return normalizedPhase < 0.5 ? -1 + 4 * normalizedPhase : 3 - 4 * normalizedPhase case 'square': return normalizedPhase < 0.5 ? 1 : -1 case 'sawtooth': return 2 * normalizedPhase - 1 case 'random': return Math.sin(normalizedPhase * 2 * Math.PI) default: return 0 } }, [waveform]) useEffect(() => { if (!lfoRef.current) { lfoRef.current = new LFO(new AudioContext(), frequency, phase, waveform) } else { lfoRef.current.setFrequency(frequency) lfoRef.current.setPhase(phase) lfoRef.current.setWaveform(waveform) } }, [frequency, phase, waveform]) useEffect(() => { const canvas = canvasRef.current if (!canvas) return const ctx = canvas.getContext('2d') if (!ctx) return let time = 0 const render = () => { if (!lfoRef.current) return ctx.fillStyle = '#000000' ctx.fillRect(0, 0, CANVAS_WIDTH, CANVAS_HEIGHT) ctx.strokeStyle = '#ffffff' ctx.lineWidth = 2 ctx.beginPath() const samples = CANVAS_WIDTH const centerY = CANVAS_HEIGHT / 2 for (let x = 0; x < samples; x++) { const t = (x / samples) + time const phase = t % 1 const value = getLFOValueAtPhase(phase) const y = centerY - (value * (centerY - 4)) if (x === 0) { ctx.moveTo(x, y) } else { ctx.lineTo(x, y) } } ctx.stroke() ctx.fillStyle = '#ffffff' ctx.font = '9px monospace' ctx.textAlign = 'left' ctx.fillText(`${frequency.toFixed(2)}Hz`, 4, 12) ctx.fillText(`${phase.toFixed(0)}°`, 4, 24) time += frequency * 0.016 animationRef.current = requestAnimationFrame(render) } render() return () => { if (animationRef.current) { cancelAnimationFrame(animationRef.current) } } }, [frequency, waveform, phase, getLFOValueAtPhase]) const handleMouseDown = (e: React.MouseEvent) => { if (e.button === 2) return setIsDragging(true) dragStartRef.current = { x: e.clientX, y: e.clientY, freq: frequency, phase: phase, moved: false } } const handleMouseMove = (e: React.MouseEvent) => { if (!isDragging || !dragStartRef.current) return const deltaY = dragStartRef.current.y - e.clientY const deltaX = e.clientX - dragStartRef.current.x const distance = Math.sqrt(deltaX * deltaX + deltaY * deltaY) if (distance > 3) { dragStartRef.current.moved = true } if (!dragStartRef.current.moved) return const freqSensitivity = 0.05 let newFreq = dragStartRef.current.freq * Math.exp(deltaY * freqSensitivity) newFreq = Math.max(MIN_FREQ, Math.min(MAX_FREQ, newFreq)) const phaseSensitivity = 2 let newPhase = (dragStartRef.current.phase + deltaX * phaseSensitivity) % 360 if (newPhase < 0) newPhase += 360 onChange(newFreq, newPhase, waveform) } const handleMouseUp = () => { if (dragStartRef.current && !dragStartRef.current.moved) { onMapClick() } setIsDragging(false) dragStartRef.current = null } const handleContextMenu = (e: React.MouseEvent) => { e.preventDefault() setShowEditor(true) } const handleDoubleClick = () => { const currentIndex = WAVEFORMS.indexOf(waveform) const nextIndex = (currentIndex + 1) % WAVEFORMS.length const nextWaveform = WAVEFORMS[nextIndex] onChange(frequency, phase, nextWaveform) } const isActive = mappingModeState.isActive && mappingModeState.activeLFO === lfoIndex const hasMappings = mappings.length > 0 return (
setShowMappings(true)} onMouseOut={() => setShowMappings(false)} /> {isActive && (
CLICK PARAM TO MAP | ESC TO EXIT
)} {showMappings && hasMappings && (
{mappings.map((m, i) => { const meta = parameterRegistry.getMetadata(m.targetParam) return (
{meta?.label ?? m.targetParam} ({m.depth}%)
) })}
)} {showEditor && ( setShowEditor(false)} /> )}
) }