diff --git a/public/worklets/fold-crush-processor.js b/public/worklets/fold-crush-processor.js new file mode 100644 index 00000000..0673e856 --- /dev/null +++ b/public/worklets/fold-crush-processor.js @@ -0,0 +1,109 @@ +class FoldCrushProcessor extends AudioWorkletProcessor { + constructor() { + super() + + this.clipMode = 'wrap' + this.drive = 1 + this.bitDepth = 16 + this.crushAmount = 0 + this.bitcrushPhase = 0 + this.lastCrushedValue = 0 + + this.port.onmessage = (event) => { + const { type, value } = event.data + switch (type) { + case 'clipMode': + this.clipMode = value + break + case 'drive': + this.drive = value + break + case 'bitDepth': + this.bitDepth = value + break + case 'crushAmount': + this.crushAmount = value + break + } + } + } + + wrap(sample) { + const range = 2.0 + let wrapped = sample + while (wrapped > 1.0) wrapped -= range + while (wrapped < -1.0) wrapped += range + return wrapped + } + + clamp(sample) { + return Math.max(-1.0, Math.min(1.0, sample)) + } + + fold(sample) { + let folded = sample + while (folded > 1.0 || folded < -1.0) { + if (folded > 1.0) { + folded = 2.0 - folded + } + if (folded < -1.0) { + folded = -2.0 - folded + } + } + return folded + } + + processWavefolder(sample) { + switch (this.clipMode) { + case 'wrap': + return this.wrap(sample) + case 'clamp': + return this.clamp(sample) + case 'fold': + return this.fold(sample) + default: + return sample + } + } + + processBitcrush(sample) { + if (this.crushAmount === 0 && this.bitDepth === 16) { + return sample + } + + const step = Math.pow(0.5, this.bitDepth) + const phaseIncrement = 1 - (this.crushAmount / 100) + + this.bitcrushPhase += phaseIncrement + + if (this.bitcrushPhase >= 1.0) { + this.bitcrushPhase -= 1.0 + const crushed = Math.floor(sample / step + 0.5) * step + this.lastCrushedValue = Math.max(-1, Math.min(1, crushed)) + return this.lastCrushedValue + } else { + return this.lastCrushedValue + } + } + + process(inputs, outputs) { + const input = inputs[0] + const output = outputs[0] + + if (input.length > 0 && output.length > 0) { + const inputChannel = input[0] + const outputChannel = output[0] + + for (let i = 0; i < inputChannel.length; i++) { + const driven = inputChannel[i] * this.drive + let processed = this.processWavefolder(driven) + processed = this.processBitcrush(processed) + outputChannel[i] = processed + } + } + + return true + } +} + +registerProcessor('fold-crush-processor', FoldCrushProcessor) diff --git a/src/App.tsx b/src/App.tsx index 8fc8190a..d0f15990 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -24,6 +24,11 @@ function App() { const playbackManagerRef = useRef(null) const downloadServiceRef = useRef(new DownloadService()) const animationFrameRef = useRef(null) + const formulasRef = useRef(formulas) + + useEffect(() => { + formulasRef.current = formulas + }, [formulas]) useEffect(() => { effectSettings.setKey('masterVolume', engineValues.masterVolume) @@ -34,21 +39,21 @@ function App() { setQueued(null) } - const playFormula = (formula: string, id: string) => { + const playFormula = async (formula: string, id: string) => { const sampleRate = getSampleRateFromIndex(engineValues.sampleRate) const duration = engineValues.loopDuration if (!playbackManagerRef.current) { playbackManagerRef.current = new PlaybackManager({ sampleRate, duration }) } else { - playbackManagerRef.current.updateOptions({ sampleRate, duration }) + await playbackManagerRef.current.updateOptions({ sampleRate, duration }) } playbackManagerRef.current.stop() playbackManagerRef.current.setEffects(effectValues) playbackManagerRef.current.setPitch(engineValues.pitch ?? 1) - const success = playbackManagerRef.current.play(formula, sampleRate, duration) + const success = await playbackManagerRef.current.play(formula, sampleRate, duration) if (success) { setPlaying(id) @@ -94,7 +99,7 @@ function App() { setQueued(id) if (playbackManagerRef.current) { playbackManagerRef.current.scheduleNextTrack(() => { - const queuedFormula = formulas.flat()[parseInt(id.split('-')[0]) * 2 + parseInt(id.split('-')[1])] + const queuedFormula = formulasRef.current.flat()[parseInt(id.split('-')[0]) * 2 + parseInt(id.split('-')[1])] if (queuedFormula) { playFormula(queuedFormula, id) } @@ -112,7 +117,7 @@ function App() { engineSettings.setKey(parameterId as keyof typeof engineValues, value) if (parameterId === 'masterVolume' && playbackManagerRef.current) { - playbackManagerRef.current.setEffects(effectValues) + playbackManagerRef.current.setEffects({ ...effectValues, masterVolume: value }) } if (parameterId === 'pitch' && playbackManagerRef.current) { @@ -141,19 +146,25 @@ function App() { const id = `${row}-${col}` const newFormula = generateRandomFormula(engineValues.complexity) - setFormulas(prevFormulas => { - const newFormulas = [...prevFormulas] - newFormulas[row] = [...newFormulas[row]] - newFormulas[row][col] = newFormula - return newFormulas - }) - if (playing === id && playbackManagerRef.current) { setRegenerating(id) playbackManagerRef.current.scheduleNextTrack(() => { + setFormulas(prevFormulas => { + const newFormulas = [...prevFormulas] + newFormulas[row] = [...newFormulas[row]] + newFormulas[row][col] = newFormula + return newFormulas + }) playFormula(newFormula, id) setRegenerating(null) }) + } else { + setFormulas(prevFormulas => { + const newFormulas = [...prevFormulas] + newFormulas[row] = [...newFormulas[row]] + newFormulas[row][col] = newFormula + return newFormulas + }) } } diff --git a/src/components/EffectsBar.tsx b/src/components/EffectsBar.tsx index acfd6efa..93fa11ae 100644 --- a/src/components/EffectsBar.tsx +++ b/src/components/EffectsBar.tsx @@ -17,62 +17,125 @@ export function EffectsBar({ values, onChange }: EffectsBarProps) { return value.toString() } + const renderFilterEffect = (effect: typeof EFFECTS[number]) => { + const filterGroups = [ + { prefix: 'hp', label: 'HP' }, + { prefix: 'lp', label: 'LP' }, + { prefix: 'bp', label: 'BP' } + ] + + return ( +
+

+ {effect.name.toUpperCase()} +

+
+ {filterGroups.map(group => { + const enableParam = effect.parameters.find(p => p.id === `${group.prefix}Enable`) + const freqParam = effect.parameters.find(p => p.id === `${group.prefix}Freq`) + const resParam = effect.parameters.find(p => p.id === `${group.prefix}Res`) + + if (!enableParam || !freqParam || !resParam) return null + + return ( +
+ onChange(enableParam.id, checked ? 1 : 0)} + vertical + /> +
+ onChange(freqParam.id, value)} + valueId={freqParam.id} + /> + onChange(resParam.id, value)} + valueId={resParam.id} + /> +
+
+ ) + })} +
+
+ ) + } + return (
- {EFFECTS.map(effect => ( -
-
-

- {effect.name.toUpperCase()} -

- {effect.bypassable && ( - onChange(`${effect.id}Bypass`, !checked)} - label={Boolean(values[`${effect.id}Bypass`]) ? 'OFF' : 'ON'} - /> - )} -
-
- {effect.parameters.map(param => { - const isSwitch = param.min === 0 && param.max === 1 && param.step === 1 + {EFFECTS.map(effect => { + if (effect.id === 'filter') { + return renderFilterEffect(effect) + } - if (isSwitch) { - return ( -
-
- - {param.label.toUpperCase()} - - onChange(param.id, checked ? 1 : 0)} - label={Boolean(values[param.id]) ? 'ON' : 'OFF'} - /> -
-
- ) - } - - return ( - onChange(param.id, value)} - formatValue={param.id === 'clipMode' ? formatValue : undefined} - valueId={param.id} + return ( +
+
+

+ {effect.name.toUpperCase()} +

+ {effect.bypassable && ( + onChange(`${effect.id}Bypass`, !checked)} + label={Boolean(values[`${effect.id}Bypass`]) ? 'OFF' : 'ON'} /> - ) - })} + )} +
+
+ {effect.parameters.map(param => { + const isSwitch = param.min === 0 && param.max === 1 && param.step === 1 + + if (isSwitch) { + return ( +
+
+ + {param.label.toUpperCase()} + + onChange(param.id, checked ? 1 : 0)} + label={Boolean(values[param.id]) ? 'ON' : 'OFF'} + /> +
+
+ ) + } + + return ( + onChange(param.id, value)} + formatValue={param.id === 'clipMode' ? formatValue : undefined} + valueId={param.id} + /> + ) + })} +
-
- ))} + ) + })}
) diff --git a/src/components/Switch.tsx b/src/components/Switch.tsx index 1b5df511..dc4eb265 100644 --- a/src/components/Switch.tsx +++ b/src/components/Switch.tsx @@ -2,9 +2,29 @@ interface SwitchProps { checked: boolean onChange: (checked: boolean) => void label?: string + vertical?: boolean } -export function Switch({ checked, onChange, label }: SwitchProps) { +export function Switch({ checked, onChange, label, vertical = false }: SwitchProps) { + if (vertical) { + return ( +