Safer fold and crush section

This commit is contained in:
2025-10-03 23:34:45 +02:00
parent 697be5cf65
commit 0fc7ffdee0
18 changed files with 189 additions and 105 deletions

View File

@ -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
squash(x) {
return x / (1 + Math.abs(x))
}
if (folded < -1.0) {
folded = -2.0 - folded
soft(x, k) {
return Math.tanh(x * (1 + k))
}
hard(x, k) {
return this.clamp((1 + k) * x, -1, 1)
}
return folded
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
}
}

View File

@ -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)

View File

@ -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 (
<div className="flex flex-col gap-1">
<label className="font-mono text-[9px] tracking-[0.15em] text-white">
{label.toUpperCase()}
</label>
<select
value={value}
onChange={(e) => onChange(e.target.value)}
className="bg-black text-white border-2 border-white font-mono text-[10px] tracking-[0.1em] px-2 py-1 cursor-pointer hover:bg-white hover:text-black transition-colors"
>
{options.map((option) => (
<option key={option.value} value={option.value}>
{option.label.toUpperCase()}
</option>
))}
</select>
</div>
)
}

View File

@ -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) {
</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) {
@ -121,13 +126,12 @@ export function EffectsBar({ values, onChange }: EffectsBarProps) {
<Slider
key={param.id}
label={param.label}
value={values[param.id] as number ?? param.default}
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)}
formatValue={param.id === 'clipMode' ? formatValue : undefined}
valueId={param.id}
/>
)

View File

@ -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<string, number | boolean> {
const defaults: Record<string, number | boolean> = {}
export function getDefaultEffectValues(): Record<string, number | boolean | string> {
const defaults: Record<string, number | boolean | string> = {}
EFFECTS.forEach(effect => {
effect.parameters.forEach(param => {
defaults[param.id] = param.default
@ -326,7 +333,7 @@ export function getDefaultEngineValues(): Record<string, number> {
const defaults: Record<string, number> = {}
ENGINE_CONTROLS.forEach(control => {
control.parameters.forEach(param => {
defaults[param.id] = param.default
defaults[param.id] = param.default as number
})
})
return defaults

View File

@ -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
}
}

View File

@ -72,12 +72,12 @@ export class BitcrushEffect implements Effect {
}
}
updateParams(values: Record<string, number>): void {
if (values.bitcrushDepth !== undefined) {
updateParams(values: Record<string, number | string>): 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
}
}

View File

@ -92,8 +92,8 @@ export class DelayEffect implements Effect {
}
}
updateParams(values: Record<string, number>): void {
if (values.delayTime !== undefined) {
updateParams(values: Record<string, number | string>): 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

View File

@ -2,7 +2,7 @@ export interface Effect {
readonly id: string
getInputNode(): AudioNode
getOutputNode(): AudioNode
updateParams(values: Record<string, number>): void
updateParams(values: Record<string, number | string>): void
setBypass(bypass: boolean): void
dispose(): void
}

View File

@ -44,7 +44,7 @@ export class EffectsChain {
this.masterGainNode.connect(this.outputNode)
}
updateEffects(values: Record<string, number | boolean>): void {
updateEffects(values: Record<string, number | boolean | string>): 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<string, number> = {}
const effectValues: Record<string, number | string> = {}
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) {

View File

@ -72,13 +72,13 @@ export class FilterEffect implements Effect {
}
}
updateParams(values: Record<string, number>): void {
updateParams(values: Record<string, number | string>): 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,

View File

@ -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<string, number>): void {
updateParams(values: Record<string, number | string>): 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 })

View File

@ -21,7 +21,7 @@ export class PassThroughEffect implements Effect {
setBypass(_bypass: boolean): void {
}
updateParams(_values: Record<string, number>): void {
updateParams(_values: Record<string, number | string>): void {
}
dispose(): void {

View File

@ -153,20 +153,20 @@ export class ReverbEffect implements Effect {
}
}
updateParams(values: Record<string, number>): void {
updateParams(values: Record<string, number | string>): 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,

View File

@ -96,12 +96,11 @@ export class WavefolderEffect implements Effect {
}
}
updateParams(values: Record<string, number>): void {
if (values.clipMode !== undefined) {
const modeIndex = values.clipMode
this.mode = ['wrap', 'clamp', 'fold'][modeIndex] as ClipMode || 'wrap'
updateParams(values: Record<string, number | string>): 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
}
}

View File

@ -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<T>(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
})
}))
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)
}

View File

@ -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<string, number | boolean>
export type EffectValues = Record<string, number | boolean | string>

View File

@ -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`
}