From 0fc7ffdee0c1a704dc31c9b72f69f6d2e90cdccd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rapha=C3=ABl=20Forment?= Date: Fri, 3 Oct 2025 23:34:45 +0200 Subject: [PATCH] Safer fold and crush section --- public/worklets/fold-crush-processor.js | 86 +++++++++++++------ src/App.tsx | 2 +- src/components/Dropdown.tsx | 27 ++++++ src/components/EffectsBar.tsx | 26 +++--- src/config/effects.ts | 23 +++-- src/domain/audio/AudioPlayer.ts | 2 + src/domain/audio/effects/BitcrushEffect.ts | 6 +- src/domain/audio/effects/DelayEffect.ts | 14 +-- src/domain/audio/effects/Effect.interface.ts | 2 +- src/domain/audio/effects/EffectsChain.ts | 10 +-- src/domain/audio/effects/FilterEffect.ts | 14 +-- src/domain/audio/effects/FoldCrushEffect.ts | 10 +-- src/domain/audio/effects/PassThroughEffect.ts | 2 +- src/domain/audio/effects/ReverbEffect.ts | 12 +-- src/domain/audio/effects/WavefolderEffect.ts | 9 +- src/stores/settings.ts | 39 ++++++--- src/types/effects.ts | 5 +- src/utils/formatters.ts | 5 -- 18 files changed, 189 insertions(+), 105 deletions(-) create mode 100644 src/components/Dropdown.tsx diff --git a/public/worklets/fold-crush-processor.js b/public/worklets/fold-crush-processor.js index 0673e856..6d0ee5ee 100644 --- a/public/worklets/fold-crush-processor.js +++ b/public/worklets/fold-crush-processor.js @@ -2,7 +2,7 @@ class FoldCrushProcessor extends AudioWorkletProcessor { constructor() { super() - this.clipMode = 'wrap' + this.clipMode = 'fold' this.drive = 1 this.bitDepth = 16 this.crushAmount = 0 @@ -28,39 +28,63 @@ class FoldCrushProcessor extends AudioWorkletProcessor { } } - wrap(sample) { - const range = 2.0 - let wrapped = sample - while (wrapped > 1.0) wrapped -= range - while (wrapped < -1.0) wrapped += range - return wrapped + clamp(x, min, max) { + return Math.max(min, Math.min(max, x)) } - clamp(sample) { - return Math.max(-1.0, Math.min(1.0, sample)) + mod(x, y) { + return ((x % y) + y) % y } - 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 + squash(x) { + return x / (1 + Math.abs(x)) + } + + soft(x, k) { + return Math.tanh(x * (1 + k)) + } + + hard(x, k) { + return this.clamp((1 + k) * x, -1, 1) + } + + fold(x, k) { + let y = (1 + 0.5 * k) * x + const window = this.mod(y + 1, 4) + return 1 - Math.abs(window - 2) + } + + cubic(x, k) { + const t = this.squash(Math.log1p(k)) + const cubic = (x - (t / 3) * x * x * x) / (1 - t / 3) + return this.soft(cubic, k) + } + + diode(x, k) { + const g = 1 + 2 * k + const t = this.squash(Math.log1p(k)) + const bias = 0.07 * t + const pos = this.soft(x + bias, 2 * k) + const neg = this.soft(-x + bias, 2 * k) + const y = pos - neg + const sech = 1 / Math.cosh(g * bias) + const sech2 = sech * sech + const denom = Math.max(1e-8, 2 * g * sech2) + return this.soft(y / denom, k) } processWavefolder(sample) { switch (this.clipMode) { - case 'wrap': - return this.wrap(sample) - case 'clamp': - return this.clamp(sample) + case 'soft': + return this.soft(sample, this.drive) + case 'hard': + return this.hard(sample, this.drive) case 'fold': - return this.fold(sample) + return this.fold(sample, this.drive) + case 'cubic': + return this.cubic(sample, this.drive) + case 'diode': + return this.diode(sample, this.drive) default: return sample } @@ -86,6 +110,14 @@ class FoldCrushProcessor extends AudioWorkletProcessor { } } + safetyLimiter(sample) { + const threshold = 0.8 + if (Math.abs(sample) > threshold) { + return Math.tanh(sample * 0.9) / Math.tanh(0.9) + } + return sample + } + process(inputs, outputs) { const input = inputs[0] const output = outputs[0] @@ -95,9 +127,9 @@ class FoldCrushProcessor extends AudioWorkletProcessor { const outputChannel = output[0] for (let i = 0; i < inputChannel.length; i++) { - const driven = inputChannel[i] * this.drive - let processed = this.processWavefolder(driven) + let processed = this.processWavefolder(inputChannel[i]) processed = this.processBitcrush(processed) + processed = this.safetyLimiter(processed) outputChannel[i] = processed } } diff --git a/src/App.tsx b/src/App.tsx index d0f15990..35562b21 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -125,7 +125,7 @@ function App() { } } - const handleEffectChange = (parameterId: string, value: number | boolean) => { + const handleEffectChange = (parameterId: string, value: number | boolean | string) => { effectSettings.setKey(parameterId as any, value as any) if (playbackManagerRef.current) { playbackManagerRef.current.setEffects(effectValues) diff --git a/src/components/Dropdown.tsx b/src/components/Dropdown.tsx new file mode 100644 index 00000000..a7769d5d --- /dev/null +++ b/src/components/Dropdown.tsx @@ -0,0 +1,27 @@ +interface DropdownProps { + label: string + value: string + options: { value: string; label: string }[] + onChange: (value: string) => void +} + +export function Dropdown({ label, value, options, onChange }: DropdownProps) { + return ( +
+ + +
+ ) +} diff --git a/src/components/EffectsBar.tsx b/src/components/EffectsBar.tsx index 93fa11ae..5df062d9 100644 --- a/src/components/EffectsBar.tsx +++ b/src/components/EffectsBar.tsx @@ -1,22 +1,15 @@ import { Slider } from './Slider' import { Switch } from './Switch' +import { Dropdown } from './Dropdown' import { EFFECTS } from '../config/effects' -import { getClipModeLabel } from '../utils/formatters' import type { EffectValues } from '../types/effects' interface EffectsBarProps { values: EffectValues - onChange: (parameterId: string, value: number | boolean) => void + onChange: (parameterId: string, value: number | boolean | string) => void } export function EffectsBar({ values, onChange }: EffectsBarProps) { - const formatValue = (id: string, value: number): string => { - if (id === 'clipMode') { - return getClipModeLabel(value) - } - return value.toString() - } - const renderFilterEffect = (effect: typeof EFFECTS[number]) => { const filterGroups = [ { prefix: 'hp', label: 'HP' }, @@ -98,6 +91,18 @@ export function EffectsBar({ values, onChange }: EffectsBarProps) {
{effect.parameters.map(param => { + if (param.options) { + return ( + onChange(param.id, value)} + /> + ) + } + const isSwitch = param.min === 0 && param.max === 1 && param.step === 1 if (isSwitch) { @@ -121,13 +126,12 @@ export function EffectsBar({ values, onChange }: EffectsBarProps) { onChange(param.id, value)} - formatValue={param.id === 'clipMode' ? formatValue : undefined} valueId={param.id} /> ) diff --git a/src/config/effects.ts b/src/config/effects.ts index a6b78bc4..c6f4c708 100644 --- a/src/config/effects.ts +++ b/src/config/effects.ts @@ -160,19 +160,26 @@ export const EFFECTS: EffectConfig[] = [ id: 'clipMode', label: 'Mode', min: 0, - max: 2, - default: 0, + max: 0, + default: 'fold', step: 1, - unit: '' + unit: '', + options: [ + { value: 'fold', label: 'Fold' }, + { value: 'soft', label: 'Soft' }, + { value: 'cubic', label: 'Cubic' }, + { value: 'diode', label: 'Diode' }, + { value: 'hard', label: 'Hard' } + ] }, { id: 'wavefolderDrive', label: 'Drive', - min: 1, + min: 0.001, max: 10, default: 1, step: 0.1, - unit: 'x' + unit: '' }, { id: 'bitcrushDepth', @@ -309,8 +316,8 @@ export const EFFECTS: EffectConfig[] = [ } ] -export function getDefaultEffectValues(): Record { - const defaults: Record = {} +export function getDefaultEffectValues(): Record { + const defaults: Record = {} EFFECTS.forEach(effect => { effect.parameters.forEach(param => { defaults[param.id] = param.default @@ -326,7 +333,7 @@ export function getDefaultEngineValues(): Record { const defaults: Record = {} ENGINE_CONTROLS.forEach(control => { control.parameters.forEach(param => { - defaults[param.id] = param.default + defaults[param.id] = param.default as number }) }) return defaults diff --git a/src/domain/audio/AudioPlayer.ts b/src/domain/audio/AudioPlayer.ts index 1a6c7caf..7dbf8f32 100644 --- a/src/domain/audio/AudioPlayer.ts +++ b/src/domain/audio/AudioPlayer.ts @@ -45,6 +45,7 @@ export class AudioPlayer { this.dispose() this.audioContext = new AudioContext({ sampleRate: this.sampleRate }) + this.workletRegistered = false await this.registerWorklet(this.audioContext) this.effectsChain = new EffectsChain(this.audioContext) @@ -176,5 +177,6 @@ export class AudioPlayer { this.audioContext.close() this.audioContext = null } + this.workletRegistered = false } } \ No newline at end of file diff --git a/src/domain/audio/effects/BitcrushEffect.ts b/src/domain/audio/effects/BitcrushEffect.ts index 9434bd53..a95b5706 100644 --- a/src/domain/audio/effects/BitcrushEffect.ts +++ b/src/domain/audio/effects/BitcrushEffect.ts @@ -72,12 +72,12 @@ export class BitcrushEffect implements Effect { } } - updateParams(values: Record): void { - if (values.bitcrushDepth !== undefined) { + updateParams(values: Record): void { + if (values.bitcrushDepth !== undefined && typeof values.bitcrushDepth === 'number') { this.bitDepth = values.bitcrushDepth } - if (values.bitcrushRate !== undefined) { + if (values.bitcrushRate !== undefined && typeof values.bitcrushRate === 'number') { this.crushAmount = values.bitcrushRate } } diff --git a/src/domain/audio/effects/DelayEffect.ts b/src/domain/audio/effects/DelayEffect.ts index 368e22b5..e6674538 100644 --- a/src/domain/audio/effects/DelayEffect.ts +++ b/src/domain/audio/effects/DelayEffect.ts @@ -92,8 +92,8 @@ export class DelayEffect implements Effect { } } - updateParams(values: Record): void { - if (values.delayTime !== undefined) { + updateParams(values: Record): void { + if (values.delayTime !== undefined && typeof values.delayTime === 'number') { const time = values.delayTime / 1000 this.delayNode.delayTime.setTargetAtTime( time, @@ -102,7 +102,7 @@ export class DelayEffect implements Effect { ) } - if (values.delayFeedback !== undefined) { + if (values.delayFeedback !== undefined && typeof values.delayFeedback === 'number') { const feedback = values.delayFeedback / 100 this.feedbackNode.gain.setTargetAtTime( feedback * 0.95, @@ -111,7 +111,7 @@ export class DelayEffect implements Effect { ) } - if (values.delayWetDry !== undefined) { + if (values.delayWetDry !== undefined && typeof values.delayWetDry === 'number') { const wet = values.delayWetDry / 100 this.currentWetValue = wet this.currentDryValue = 1 - wet @@ -130,7 +130,7 @@ export class DelayEffect implements Effect { } } - if (values.delayTone !== undefined) { + if (values.delayTone !== undefined && typeof values.delayTone === 'number') { const tone = values.delayTone / 100 const freq = 200 + tone * 7800 this.filterNode.frequency.setTargetAtTime( @@ -140,12 +140,12 @@ export class DelayEffect implements Effect { ) } - if (values.delaySaturation !== undefined) { + if (values.delaySaturation !== undefined && typeof values.delaySaturation === 'number') { const saturation = values.delaySaturation / 100 this.createSaturationCurve(saturation) } - if (values.delayFlutter !== undefined) { + if (values.delayFlutter !== undefined && typeof values.delayFlutter === 'number') { const flutter = values.delayFlutter / 100 const baseDelay = this.delayNode.delayTime.value const modDepth = baseDelay * flutter * 0.1 diff --git a/src/domain/audio/effects/Effect.interface.ts b/src/domain/audio/effects/Effect.interface.ts index af2dbf12..b60b8ea3 100644 --- a/src/domain/audio/effects/Effect.interface.ts +++ b/src/domain/audio/effects/Effect.interface.ts @@ -2,7 +2,7 @@ export interface Effect { readonly id: string getInputNode(): AudioNode getOutputNode(): AudioNode - updateParams(values: Record): void + updateParams(values: Record): void setBypass(bypass: boolean): void dispose(): void } diff --git a/src/domain/audio/effects/EffectsChain.ts b/src/domain/audio/effects/EffectsChain.ts index 2aa51325..720b55a2 100644 --- a/src/domain/audio/effects/EffectsChain.ts +++ b/src/domain/audio/effects/EffectsChain.ts @@ -44,7 +44,7 @@ export class EffectsChain { this.masterGainNode.connect(this.outputNode) } - updateEffects(values: Record): void { + updateEffects(values: Record): void { for (const effect of this.effects) { const effectId = effect.id const bypassKey = `${effectId}Bypass` @@ -53,13 +53,13 @@ export class EffectsChain { effect.setBypass(Boolean(values[bypassKey])) } - const numericValues: Record = {} + const effectValues: Record = {} for (const [key, value] of Object.entries(values)) { - if (typeof value === 'number') { - numericValues[key] = value + if (typeof value === 'number' || typeof value === 'string') { + effectValues[key] = value } } - effect.updateParams(numericValues) + effect.updateParams(effectValues) } if (values.masterVolume !== undefined) { diff --git a/src/domain/audio/effects/FilterEffect.ts b/src/domain/audio/effects/FilterEffect.ts index c066c963..cb716967 100644 --- a/src/domain/audio/effects/FilterEffect.ts +++ b/src/domain/audio/effects/FilterEffect.ts @@ -72,13 +72,13 @@ export class FilterEffect implements Effect { } } - updateParams(values: Record): void { + updateParams(values: Record): void { if (values.hpEnable !== undefined) { this.hpEnabled = values.hpEnable === 1 this.updateBypassState() } - if (values.hpFreq !== undefined) { + if (values.hpFreq !== undefined && typeof values.hpFreq === 'number') { this.hpFilter.frequency.cancelScheduledValues(this.audioContext.currentTime) this.hpFilter.frequency.setValueAtTime( this.hpFilter.frequency.value, @@ -90,7 +90,7 @@ export class FilterEffect implements Effect { ) } - if (values.hpRes !== undefined) { + if (values.hpRes !== undefined && typeof values.hpRes === 'number') { this.hpFilter.Q.cancelScheduledValues(this.audioContext.currentTime) this.hpFilter.Q.setValueAtTime( this.hpFilter.Q.value, @@ -107,7 +107,7 @@ export class FilterEffect implements Effect { this.updateBypassState() } - if (values.lpFreq !== undefined) { + if (values.lpFreq !== undefined && typeof values.lpFreq === 'number') { this.lpFilter.frequency.cancelScheduledValues(this.audioContext.currentTime) this.lpFilter.frequency.setValueAtTime( this.lpFilter.frequency.value, @@ -119,7 +119,7 @@ export class FilterEffect implements Effect { ) } - if (values.lpRes !== undefined) { + if (values.lpRes !== undefined && typeof values.lpRes === 'number') { this.lpFilter.Q.cancelScheduledValues(this.audioContext.currentTime) this.lpFilter.Q.setValueAtTime( this.lpFilter.Q.value, @@ -136,7 +136,7 @@ export class FilterEffect implements Effect { this.updateBypassState() } - if (values.bpFreq !== undefined) { + if (values.bpFreq !== undefined && typeof values.bpFreq === 'number') { this.bpFilter.frequency.cancelScheduledValues(this.audioContext.currentTime) this.bpFilter.frequency.setValueAtTime( this.bpFilter.frequency.value, @@ -148,7 +148,7 @@ export class FilterEffect implements Effect { ) } - if (values.bpRes !== undefined) { + if (values.bpRes !== undefined && typeof values.bpRes === 'number') { this.bpFilter.Q.cancelScheduledValues(this.audioContext.currentTime) this.bpFilter.Q.setValueAtTime( this.bpFilter.Q.value, diff --git a/src/domain/audio/effects/FoldCrushEffect.ts b/src/domain/audio/effects/FoldCrushEffect.ts index 68bc7f67..d9fcc308 100644 --- a/src/domain/audio/effects/FoldCrushEffect.ts +++ b/src/domain/audio/effects/FoldCrushEffect.ts @@ -15,8 +15,8 @@ export class FoldCrushEffect implements Effect { this.wetNode = audioContext.createGain() this.dryNode = audioContext.createGain() - this.wetNode.gain.value = 1 - this.dryNode.gain.value = 0 + this.wetNode.gain.value = 0 + this.dryNode.gain.value = 1 this.inputNode.connect(this.dryNode) this.dryNode.connect(this.outputNode) @@ -51,13 +51,11 @@ export class FoldCrushEffect implements Effect { } } - updateParams(values: Record): void { + updateParams(values: Record): void { if (!this.processorNode) return if (values.clipMode !== undefined) { - const modeIndex = values.clipMode - const clipMode = ['wrap', 'clamp', 'fold'][modeIndex] || 'wrap' - this.processorNode.port.postMessage({ type: 'clipMode', value: clipMode }) + this.processorNode.port.postMessage({ type: 'clipMode', value: values.clipMode }) } if (values.wavefolderDrive !== undefined) { this.processorNode.port.postMessage({ type: 'drive', value: values.wavefolderDrive }) diff --git a/src/domain/audio/effects/PassThroughEffect.ts b/src/domain/audio/effects/PassThroughEffect.ts index 5dcb82be..d59721c7 100644 --- a/src/domain/audio/effects/PassThroughEffect.ts +++ b/src/domain/audio/effects/PassThroughEffect.ts @@ -21,7 +21,7 @@ export class PassThroughEffect implements Effect { setBypass(_bypass: boolean): void { } - updateParams(_values: Record): void { + updateParams(_values: Record): void { } dispose(): void { diff --git a/src/domain/audio/effects/ReverbEffect.ts b/src/domain/audio/effects/ReverbEffect.ts index 2e46fd95..075f5efb 100644 --- a/src/domain/audio/effects/ReverbEffect.ts +++ b/src/domain/audio/effects/ReverbEffect.ts @@ -153,20 +153,20 @@ export class ReverbEffect implements Effect { } } - updateParams(values: Record): void { + updateParams(values: Record): void { let needsRegeneration = false - if (values.reverbDecay !== undefined && values.reverbDecay !== this.currentDecay) { + if (values.reverbDecay !== undefined && typeof values.reverbDecay === 'number' && values.reverbDecay !== this.currentDecay) { this.currentDecay = values.reverbDecay needsRegeneration = true } - if (values.reverbDamping !== undefined && values.reverbDamping !== this.currentDamping) { + if (values.reverbDamping !== undefined && typeof values.reverbDamping === 'number' && values.reverbDamping !== this.currentDamping) { this.currentDamping = values.reverbDamping needsRegeneration = true } - if (values.reverbWetDry !== undefined) { + if (values.reverbWetDry !== undefined && typeof values.reverbWetDry === 'number') { const wet = values.reverbWetDry / 100 this.currentWetValue = wet this.currentDryValue = 1 - wet @@ -177,7 +177,7 @@ export class ReverbEffect implements Effect { } } - if (values.reverbPanRate !== undefined) { + if (values.reverbPanRate !== undefined && typeof values.reverbPanRate === 'number') { const rate = values.reverbPanRate this.panLfoNode.frequency.setTargetAtTime( rate, @@ -186,7 +186,7 @@ export class ReverbEffect implements Effect { ) } - if (values.reverbPanWidth !== undefined) { + if (values.reverbPanWidth !== undefined && typeof values.reverbPanWidth === 'number') { const width = values.reverbPanWidth / 100 this.panLfoGainNode.gain.setTargetAtTime( width, diff --git a/src/domain/audio/effects/WavefolderEffect.ts b/src/domain/audio/effects/WavefolderEffect.ts index 7097e364..a3b0ff78 100644 --- a/src/domain/audio/effects/WavefolderEffect.ts +++ b/src/domain/audio/effects/WavefolderEffect.ts @@ -96,12 +96,11 @@ export class WavefolderEffect implements Effect { } } - updateParams(values: Record): void { - if (values.clipMode !== undefined) { - const modeIndex = values.clipMode - this.mode = ['wrap', 'clamp', 'fold'][modeIndex] as ClipMode || 'wrap' + updateParams(values: Record): void { + if (values.clipMode !== undefined && typeof values.clipMode === 'string') { + this.mode = values.clipMode as ClipMode } - if (values.wavefolderDrive !== undefined) { + if (values.wavefolderDrive !== undefined && typeof values.wavefolderDrive === 'number') { this.drive = values.wavefolderDrive } } diff --git a/src/stores/settings.ts b/src/stores/settings.ts index e2a6e65c..cc66b8a5 100644 --- a/src/stores/settings.ts +++ b/src/stores/settings.ts @@ -1,15 +1,34 @@ -import { persistentMap } from '@nanostores/persistent' +import { map } from 'nanostores' import { getDefaultEngineValues, getDefaultEffectValues } from '../config/effects' -export const engineSettings = persistentMap('engine:', getDefaultEngineValues(), { - encode: JSON.stringify, - decode: JSON.parse -}) +const STORAGE_KEY_ENGINE = 'engine:' +const STORAGE_KEY_EFFECTS = 'effects:' -export const effectSettings = persistentMap('effects:', { +function loadFromStorage(key: string, defaults: T): T { + try { + const stored = localStorage.getItem(key) + return stored ? { ...defaults, ...JSON.parse(stored) } : defaults + } catch { + return defaults + } +} + +export const engineSettings = map(loadFromStorage(STORAGE_KEY_ENGINE, getDefaultEngineValues())) + +export const effectSettings = map(loadFromStorage(STORAGE_KEY_EFFECTS, { ...getDefaultEffectValues(), masterVolume: 75 -}, { - encode: JSON.stringify, - decode: JSON.parse -}) \ No newline at end of file +})) + +function saveToStorage() { + try { + localStorage.setItem(STORAGE_KEY_ENGINE, JSON.stringify(engineSettings.get())) + localStorage.setItem(STORAGE_KEY_EFFECTS, JSON.stringify(effectSettings.get())) + } catch (e) { + console.error('Failed to save settings:', e) + } +} + +if (typeof window !== 'undefined') { + window.addEventListener('beforeunload', saveToStorage) +} \ No newline at end of file diff --git a/src/types/effects.ts b/src/types/effects.ts index 3101ba43..a79800d4 100644 --- a/src/types/effects.ts +++ b/src/types/effects.ts @@ -3,9 +3,10 @@ export interface EffectParameter { label: string min: number max: number - default: number + default: number | string step: number unit?: string + options?: { value: string; label: string }[] } export interface EffectConfig { @@ -15,4 +16,4 @@ export interface EffectConfig { bypassable?: boolean } -export type EffectValues = Record \ No newline at end of file +export type EffectValues = Record \ No newline at end of file diff --git a/src/utils/formatters.ts b/src/utils/formatters.ts index b71ba662..39c85822 100644 --- a/src/utils/formatters.ts +++ b/src/utils/formatters.ts @@ -10,11 +10,6 @@ export function getBitDepthLabel(index: number): string { return labels[index] || '8bit' } -export function getClipModeLabel(index: number): string { - const labels = ['Wrap', 'Clamp', 'Fold'] - return labels[index] || 'Wrap' -} - export function getSampleRateLabel(index: number): string { return `${SAMPLE_RATES[index]}Hz` } \ No newline at end of file