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