Enhance FM synthesis + cleaning code architecture
This commit is contained in:
221
src/components/controls/EffectsBar.tsx
Normal file
221
src/components/controls/EffectsBar.tsx
Normal file
@ -0,0 +1,221 @@
|
||||
import { useState } from 'react'
|
||||
import { Dices } from 'lucide-react'
|
||||
import { Slider } from '../ui/Slider'
|
||||
import { Switch } from '../ui/Switch'
|
||||
import { Dropdown } from '../ui/Dropdown'
|
||||
import { EFFECTS } from '../../config/parameters'
|
||||
import type { EffectValues } from '../../types/effects'
|
||||
|
||||
interface EffectsBarProps {
|
||||
values: EffectValues
|
||||
onChange: (parameterId: string, value: number | boolean | string) => void
|
||||
onMapClick?: (paramId: string, lfoIndex: number) => void
|
||||
getMappedLFOs?: (paramId: string) => number[]
|
||||
}
|
||||
|
||||
export function EffectsBar({ values, onChange, onMapClick, getMappedLFOs }: EffectsBarProps) {
|
||||
const [activeTab, setActiveTab] = useState<string>(EFFECTS[0].id)
|
||||
const [isCollapsed, setIsCollapsed] = useState(true)
|
||||
const randomizeEffect = (effect: typeof EFFECTS[number]) => {
|
||||
effect.parameters.forEach(param => {
|
||||
if (param.id.endsWith('Enable')) return
|
||||
|
||||
if (param.options) {
|
||||
const randomOption = param.options[Math.floor(Math.random() * param.options.length)]
|
||||
onChange(param.id, randomOption.value)
|
||||
} else {
|
||||
const range = param.max - param.min
|
||||
const steps = Math.floor(range / param.step)
|
||||
const randomStep = Math.floor(Math.random() * (steps + 1))
|
||||
const randomValue = param.min + (randomStep * param.step)
|
||||
onChange(param.id, randomValue)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-black border-t-2 border-white px-2 lg:px-6 py-3 lg:py-4">
|
||||
{/* Desktop: Grid layout */}
|
||||
<div className="hidden lg:grid lg:grid-cols-4 lg:gap-4">
|
||||
{EFFECTS.map(effect => {
|
||||
return (
|
||||
<div key={effect.id} className="border-2 border-white p-3">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="font-mono text-[10px] tracking-[0.2em] text-white">
|
||||
{effect.name.toUpperCase()}
|
||||
</h3>
|
||||
<button
|
||||
onClick={() => randomizeEffect(effect)}
|
||||
className="p-1 text-white hover:bg-white hover:text-black transition-colors"
|
||||
>
|
||||
<Dices size={12} strokeWidth={2} />
|
||||
</button>
|
||||
</div>
|
||||
{effect.bypassable && (
|
||||
<Switch
|
||||
checked={!values[`${effect.id}Bypass`]}
|
||||
onChange={(checked) => onChange(`${effect.id}Bypass`, !checked)}
|
||||
label={values[`${effect.id}Bypass`] ? 'OFF' : 'ON'}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col gap-3">
|
||||
{effect.parameters.map(param => {
|
||||
if (param.options) {
|
||||
return (
|
||||
<Dropdown
|
||||
key={param.id}
|
||||
label={param.label}
|
||||
value={values[param.id] as string ?? param.default as string}
|
||||
options={param.options}
|
||||
onChange={(value) => onChange(param.id, value)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const isSwitch = param.min === 0 && param.max === 1 && param.step === 1
|
||||
|
||||
if (isSwitch) {
|
||||
return (
|
||||
<div key={param.id} className="flex flex-col gap-1 mt-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-mono text-[9px] tracking-[0.15em] text-white">
|
||||
{param.label.toUpperCase()}
|
||||
</span>
|
||||
<Switch
|
||||
checked={Boolean(values[param.id])}
|
||||
onChange={(checked) => onChange(param.id, checked ? 1 : 0)}
|
||||
label={values[param.id] ? 'ON' : 'OFF'}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Slider
|
||||
key={param.id}
|
||||
label={param.label}
|
||||
value={values[param.id] as number ?? param.default as number}
|
||||
min={param.min}
|
||||
max={param.max}
|
||||
step={param.step}
|
||||
unit={param.unit}
|
||||
onChange={(value) => onChange(param.id, value)}
|
||||
valueId={param.id}
|
||||
paramId={param.id}
|
||||
onMapClick={onMapClick}
|
||||
mappedLFOs={getMappedLFOs ? getMappedLFOs(param.id) : []}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Mobile: Tabbed layout */}
|
||||
<div className="lg:hidden flex flex-col">
|
||||
<div className="flex border-2 border-white">
|
||||
{EFFECTS.map(effect => (
|
||||
<button
|
||||
key={effect.id}
|
||||
onClick={() => {
|
||||
if (activeTab === effect.id && !isCollapsed) {
|
||||
setIsCollapsed(true)
|
||||
} else {
|
||||
setActiveTab(effect.id)
|
||||
setIsCollapsed(false)
|
||||
}
|
||||
}}
|
||||
className={`flex-1 p-2 font-mono text-[9px] tracking-[0.15em] transition-colors ${
|
||||
activeTab === effect.id && !isCollapsed
|
||||
? 'bg-white text-black'
|
||||
: 'bg-black text-white hover:bg-white/10'
|
||||
}`}
|
||||
>
|
||||
{effect.name.toUpperCase()}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{!isCollapsed && EFFECTS.map(effect => {
|
||||
if (activeTab !== effect.id) return null
|
||||
return (
|
||||
<div key={effect.id} className="border-2 border-t-0 border-white p-3">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => randomizeEffect(effect)}
|
||||
className="p-1 text-white hover:bg-white hover:text-black transition-colors"
|
||||
>
|
||||
<Dices size={14} strokeWidth={2} />
|
||||
</button>
|
||||
</div>
|
||||
{effect.bypassable && (
|
||||
<Switch
|
||||
checked={!values[`${effect.id}Bypass`]}
|
||||
onChange={(checked) => onChange(`${effect.id}Bypass`, !checked)}
|
||||
label={values[`${effect.id}Bypass`] ? 'OFF' : 'ON'}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col gap-3">
|
||||
{effect.parameters.map(param => {
|
||||
if (param.options) {
|
||||
return (
|
||||
<Dropdown
|
||||
key={param.id}
|
||||
label={param.label}
|
||||
value={values[param.id] as string ?? param.default as string}
|
||||
options={param.options}
|
||||
onChange={(value) => onChange(param.id, value)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const isSwitch = param.min === 0 && param.max === 1 && param.step === 1
|
||||
|
||||
if (isSwitch) {
|
||||
return (
|
||||
<div key={param.id} className="flex flex-col gap-1 mt-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-mono text-[9px] tracking-[0.15em] text-white">
|
||||
{param.label.toUpperCase()}
|
||||
</span>
|
||||
<Switch
|
||||
checked={Boolean(values[param.id])}
|
||||
onChange={(checked) => onChange(param.id, checked ? 1 : 0)}
|
||||
label={values[param.id] ? 'ON' : 'OFF'}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Slider
|
||||
key={param.id}
|
||||
label={param.label}
|
||||
value={values[param.id] as number ?? param.default as number}
|
||||
min={param.min}
|
||||
max={param.max}
|
||||
step={param.step}
|
||||
unit={param.unit}
|
||||
onChange={(value) => onChange(param.id, value)}
|
||||
valueId={param.id}
|
||||
paramId={param.id}
|
||||
onMapClick={onMapClick}
|
||||
mappedLFOs={getMappedLFOs ? getMappedLFOs(param.id) : []}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
95
src/components/controls/EngineControls.tsx
Normal file
95
src/components/controls/EngineControls.tsx
Normal file
@ -0,0 +1,95 @@
|
||||
import { useStore } from '@nanostores/react'
|
||||
import { ENGINE_CONTROLS } from '../../config/parameters'
|
||||
import { getComplexityLabel, getBitDepthLabel, getSampleRateLabel, getAlgorithmLabel } from '../../utils/formatters'
|
||||
import type { EffectValues } from '../../types/effects'
|
||||
import { Knob } from '../ui/Knob'
|
||||
import { synthesisMode } from '../../stores/synthesisMode'
|
||||
|
||||
interface EngineControlsProps {
|
||||
values: EffectValues
|
||||
onChange: (parameterId: string, value: number) => void
|
||||
onMapClick?: (paramId: string, lfoIndex: number) => void
|
||||
getMappedLFOs?: (paramId: string) => number[]
|
||||
showOnlySliders?: boolean
|
||||
showOnlyKnobs?: boolean
|
||||
}
|
||||
|
||||
const KNOB_PARAMS = ['masterVolume', 'pitch', 'a', 'b', 'c', 'd']
|
||||
|
||||
export function EngineControls({ values, onChange, onMapClick, getMappedLFOs, showOnlySliders, showOnlyKnobs }: EngineControlsProps) {
|
||||
const mode = useStore(synthesisMode)
|
||||
|
||||
const formatValue = (id: string, value: number): string => {
|
||||
switch (id) {
|
||||
case 'sampleRate':
|
||||
return getSampleRateLabel(value)
|
||||
case 'complexity':
|
||||
return getComplexityLabel(value)
|
||||
case 'bitDepth':
|
||||
return getBitDepthLabel(value)
|
||||
case 'fmAlgorithm':
|
||||
return getAlgorithmLabel(value)
|
||||
default: {
|
||||
const param = ENGINE_CONTROLS[0].parameters.find(p => p.id === id)
|
||||
return `${value}${param?.unit || ''}`
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-2 md:gap-4 xl:gap-6">
|
||||
{ENGINE_CONTROLS[0].parameters.map(param => {
|
||||
const useKnob = KNOB_PARAMS.includes(param.id)
|
||||
|
||||
if (mode === 'bytebeat' && (param.id === 'fmAlgorithm' || param.id === 'fmFeedback')) return null
|
||||
if (mode === 'fm' && (param.id === 'complexity' || param.id === 'bitDepth')) return null
|
||||
|
||||
if (showOnlySliders && useKnob) return null
|
||||
if (showOnlyKnobs && !useKnob) return null
|
||||
|
||||
if (useKnob) {
|
||||
return (
|
||||
<Knob
|
||||
key={param.id}
|
||||
label={param.label}
|
||||
value={(values[param.id] as number) ?? param.default}
|
||||
min={param.min as number}
|
||||
max={param.max as number}
|
||||
step={param.step as number}
|
||||
unit={param.unit}
|
||||
onChange={(value) => onChange(param.id, value)}
|
||||
formatValue={formatValue}
|
||||
valueId={param.id}
|
||||
size={40}
|
||||
paramId={param.id}
|
||||
onMapClick={onMapClick}
|
||||
mappedLFOs={getMappedLFOs ? getMappedLFOs(param.id) : []}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={param.id} className="flex flex-col gap-1 min-w-[70px] flex-1 lg:flex-initial lg:min-w-[90px] xl:min-w-[100px]">
|
||||
<div className="flex justify-between items-baseline gap-1">
|
||||
<label className="font-mono text-[7px] lg:text-[9px] tracking-[0.1em] lg:tracking-[0.15em] text-white truncate">
|
||||
{param.label.toUpperCase()}
|
||||
</label>
|
||||
<span className="font-mono text-[7px] lg:text-[9px] text-white whitespace-nowrap">
|
||||
{formatValue(param.id, (values[param.id] as number) ?? param.default)}
|
||||
</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min={param.min}
|
||||
max={param.max}
|
||||
step={param.step}
|
||||
value={(values[param.id] as number) ?? param.default}
|
||||
onChange={(e) => onChange(param.id, Number(e.target.value))}
|
||||
className="w-full h-[2px] bg-white appearance-none cursor-pointer"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
61
src/components/controls/LFOPanel.tsx
Normal file
61
src/components/controls/LFOPanel.tsx
Normal file
@ -0,0 +1,61 @@
|
||||
import { useStore } from '@nanostores/react'
|
||||
import { lfoSettings } from '../../stores/settings'
|
||||
import { toggleMappingMode } from '../../stores/mappingMode'
|
||||
import { LFOScope } from '../scopes/LFOScope'
|
||||
import type { LFOConfig } from '../../stores/settings'
|
||||
import type { LFOWaveform } from '../../domain/modulation/LFO'
|
||||
|
||||
interface LFOPanelProps {
|
||||
onChange: (lfoIndex: number, config: LFOConfig) => void
|
||||
onUpdateDepth: (lfoIndex: number, paramId: string, depth: number) => void
|
||||
onRemoveMapping: (lfoIndex: number, paramId: string) => void
|
||||
}
|
||||
|
||||
type LFOKey = 'lfo1' | 'lfo2' | 'lfo3' | 'lfo4'
|
||||
|
||||
export function LFOPanel({ onChange, onUpdateDepth, onRemoveMapping }: LFOPanelProps) {
|
||||
const lfoValues = useStore(lfoSettings)
|
||||
|
||||
const handleLFOChange = (lfoKey: LFOKey, lfoIndex: number, frequency: number, phase: number, waveform: LFOWaveform) => {
|
||||
const lfo = lfoValues[lfoKey]
|
||||
const updated = { ...lfo, frequency, phase, waveform }
|
||||
lfoSettings.setKey(lfoKey, updated)
|
||||
onChange(lfoIndex, updated)
|
||||
}
|
||||
|
||||
const handleMapClick = (_lfoKey: LFOKey, lfoIndex: number) => {
|
||||
toggleMappingMode(lfoIndex)
|
||||
}
|
||||
|
||||
const lfoConfigs: Array<{ key: LFOKey; index: number }> = [
|
||||
{ key: 'lfo1', index: 0 },
|
||||
{ key: 'lfo2', index: 1 },
|
||||
{ key: 'lfo3', index: 2 },
|
||||
{ key: 'lfo4', index: 3 }
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="bg-black border-t-2 border-white">
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-[1px] bg-white p-[1px]">
|
||||
{lfoConfigs.map(({ key, index }) => {
|
||||
const lfo = lfoValues[key]
|
||||
return (
|
||||
<div key={key} className="px-2 py-2 flex items-center bg-black">
|
||||
<LFOScope
|
||||
lfoIndex={index}
|
||||
waveform={lfo.waveform}
|
||||
frequency={lfo.frequency}
|
||||
phase={lfo.phase}
|
||||
mappings={lfo.mappings}
|
||||
onChange={(freq, phase, waveform) => handleLFOChange(key, index, freq, phase, waveform)}
|
||||
onMapClick={() => handleMapClick(key, index)}
|
||||
onUpdateDepth={(paramId, depth) => onUpdateDepth(index, paramId, depth)}
|
||||
onRemoveMapping={(paramId) => onRemoveMapping(index, paramId)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
81
src/components/controls/MappingEditor.tsx
Normal file
81
src/components/controls/MappingEditor.tsx
Normal file
@ -0,0 +1,81 @@
|
||||
import { parameterRegistry } from '../../domain/modulation/ParameterRegistry'
|
||||
|
||||
interface Mapping {
|
||||
targetParam: string
|
||||
depth: number
|
||||
}
|
||||
|
||||
interface MappingEditorProps {
|
||||
lfoIndex: number
|
||||
mappings: Mapping[]
|
||||
onUpdateDepth: (paramId: string, depth: number) => void
|
||||
onRemoveMapping: (paramId: string) => void
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export function MappingEditor({ lfoIndex, mappings, onUpdateDepth, onRemoveMapping, onClose }: MappingEditorProps) {
|
||||
return (
|
||||
<>
|
||||
<div className="fixed inset-0 z-50" onClick={onClose} />
|
||||
<div className="fixed inset-0 pointer-events-none flex items-center justify-center z-50">
|
||||
<div
|
||||
className="bg-black border-2 border-white p-4 min-w-[300px] max-w-[400px] pointer-events-auto"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<h2 className="font-mono text-sm tracking-[0.15em] text-white">
|
||||
LFO {lfoIndex + 1} MAPPINGS
|
||||
</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="font-mono text-xs text-white hover:bg-white hover:text-black px-2 py-1 border-2 border-white"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{mappings.length === 0 ? (
|
||||
<div className="font-mono text-xs text-white text-center py-4">
|
||||
NO MAPPINGS
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{mappings.map((mapping) => {
|
||||
const meta = parameterRegistry.getMetadata(mapping.targetParam)
|
||||
return (
|
||||
<div key={mapping.targetParam} className="border-2 border-white p-2">
|
||||
<div className="flex justify-between items-center mb-2">
|
||||
<span className="font-mono text-xs text-white tracking-[0.1em]">
|
||||
{meta?.label ?? mapping.targetParam}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => onRemoveMapping(mapping.targetParam)}
|
||||
className="font-mono text-[10px] text-white hover:bg-white hover:text-black px-1 border border-white"
|
||||
>
|
||||
REMOVE
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="100"
|
||||
step="1"
|
||||
value={mapping.depth}
|
||||
onChange={(e) => onUpdateDepth(mapping.targetParam, Number(e.target.value))}
|
||||
className="flex-1 h-[2px] appearance-none cursor-pointer slider bg-white"
|
||||
/>
|
||||
<span className="font-mono text-xs text-white w-12 text-right">
|
||||
{mapping.depth}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user