239 lines
7.1 KiB
TypeScript
239 lines
7.1 KiB
TypeScript
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<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)
|
|
|
|
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<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>
|
|
)
|
|
}
|