slightly better
This commit is contained in:
238
src/components/LFOScope.tsx
Normal file
238
src/components/LFOScope.tsx
Normal file
@ -0,0 +1,238 @@
|
||||
import { useEffect, useRef, useState } 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<HTMLCanvasElement>(null)
|
||||
const lfoRef = useRef<LFO | null>(null)
|
||||
const animationRef = useRef<number | null>(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)
|
||||
|
||||
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])
|
||||
|
||||
const getLFOValueAtPhase = (phase: number): number => {
|
||||
const normalizedPhase = phase % 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
|
||||
}
|
||||
}
|
||||
|
||||
const handleMouseDown = (e: React.MouseEvent<HTMLCanvasElement>) => {
|
||||
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<HTMLCanvasElement>) => {
|
||||
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<HTMLCanvasElement>) => {
|
||||
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 (
|
||||
<div className="flex items-center gap-1 w-full relative">
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
width={CANVAS_WIDTH}
|
||||
height={CANVAS_HEIGHT}
|
||||
className={`border-2 border-white cursor-move flex-1 ${
|
||||
isActive ? 'animate-pulse' : ''
|
||||
}`}
|
||||
style={{ maxWidth: CANVAS_WIDTH }}
|
||||
onMouseDown={handleMouseDown}
|
||||
onMouseMove={handleMouseMove}
|
||||
onMouseUp={handleMouseUp}
|
||||
onMouseLeave={handleMouseUp}
|
||||
onContextMenu={handleContextMenu}
|
||||
onDoubleClick={handleDoubleClick}
|
||||
onMouseEnter={() => setShowMappings(true)}
|
||||
onMouseOut={() => setShowMappings(false)}
|
||||
/>
|
||||
|
||||
{isActive && (
|
||||
<div className="absolute top-2 right-2 bg-black border border-white px-2 py-1 pointer-events-none">
|
||||
<div className="font-mono text-[9px] text-white tracking-wider">
|
||||
CLICK PARAM TO MAP | ESC TO EXIT
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showMappings && hasMappings && (
|
||||
<div className="absolute left-2 bottom-2 bg-black border border-white p-1 z-10 pointer-events-none">
|
||||
<div className="font-mono text-[8px] text-white">
|
||||
{mappings.map((m, i) => {
|
||||
const meta = parameterRegistry.getMetadata(m.targetParam)
|
||||
return (
|
||||
<div key={i} className="whitespace-nowrap">
|
||||
{meta?.label ?? m.targetParam} ({m.depth}%)
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showEditor && (
|
||||
<MappingEditor
|
||||
lfoIndex={lfoIndex}
|
||||
mappings={mappings}
|
||||
onUpdateDepth={onUpdateDepth}
|
||||
onRemoveMapping={onRemoveMapping}
|
||||
onClose={() => setShowEditor(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user