slightly better

This commit is contained in:
2025-10-06 02:16:23 +02:00
parent ba37b94908
commit ac772054c9
35 changed files with 1874 additions and 390 deletions

238
src/components/LFOScope.tsx Normal file
View 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>
)
}