This commit is contained in:
2025-10-06 16:36:59 +02:00
parent 90f2f4209c
commit 9d26ea5cd7
15 changed files with 1031 additions and 595 deletions

View File

@ -36,7 +36,7 @@ export function EffectsBar({ values, onChange, onMapClick, getMappedLFOs }: Effe
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">
<div className="hidden lg:grid lg:grid-cols-6 lg:gap-3">
{EFFECTS.map(effect => {
return (
<div key={effect.id} className="border-2 border-white p-3">

View File

@ -67,6 +67,19 @@ export function Knob({
e.preventDefault()
}
const handleTouchStart = (e: React.TouchEvent) => {
if (isInMappingMode && paramId && mappingModeState.activeLFO !== null && onMapClick) {
onMapClick(paramId, mappingModeState.activeLFO)
e.preventDefault()
return
}
setIsDragging(true)
startYRef.current = e.touches[0].clientY
startValueRef.current = value
e.preventDefault()
}
const handleMouseMove = useCallback((e: MouseEvent) => {
if (!isDragging) return
@ -79,26 +92,48 @@ export function Knob({
onChange(steppedValue)
}, [isDragging, max, min, step, onChange])
const handleTouchMove = useCallback((e: TouchEvent) => {
if (!isDragging) return
const deltaY = startYRef.current - e.touches[0].clientY
const range = max - min
const sensitivity = range / 200
const newValue = Math.max(min, Math.min(max, startValueRef.current + deltaY * sensitivity))
const steppedValue = Math.round(newValue / step) * step
onChange(steppedValue)
e.preventDefault()
}, [isDragging, max, min, step, onChange])
const handleMouseUp = useCallback(() => {
setIsDragging(false)
}, [])
const handleTouchEnd = useCallback(() => {
setIsDragging(false)
}, [])
useEffect(() => {
if (isDragging) {
window.addEventListener('mousemove', handleMouseMove)
window.addEventListener('mouseup', handleMouseUp)
window.addEventListener('touchmove', handleTouchMove, { passive: false })
window.addEventListener('touchend', handleTouchEnd)
return () => {
window.removeEventListener('mousemove', handleMouseMove)
window.removeEventListener('mouseup', handleMouseUp)
window.removeEventListener('touchmove', handleTouchMove)
window.removeEventListener('touchend', handleTouchEnd)
}
}
}, [isDragging, handleMouseMove, handleMouseUp])
}, [isDragging, handleMouseMove, handleMouseUp, handleTouchMove, handleTouchEnd])
return (
<div className="relative flex flex-col items-center">
<div
className={`relative select-none ${isInMappingMode ? 'cursor-pointer' : 'cursor-ns-resize'}`}
onMouseDown={handleMouseDown}
onTouchStart={handleTouchStart}
style={{ width: size, height: size }}
>
<svg

View File

@ -45,8 +45,8 @@ export const ENGINE_CONTROLS: ParameterGroup[] = [
id: 'masterVolume',
label: 'Vol',
min: 0,
max: 100,
default: 75,
max: 80,
default: 50,
step: 1,
unit: '%'
},
@ -118,6 +118,111 @@ export const ENGINE_CONTROLS: ParameterGroup[] = [
]
export const EFFECTS: ParameterGroup[] = [
{
id: 'ring',
name: 'Ring Mod',
bypassable: true,
parameters: [
{
id: 'ringShape',
label: 'Shape',
min: 0,
max: 0,
default: 'sine',
step: 1,
unit: '',
options: [
{ value: 'sine', label: 'Sine' },
{ value: 'square', label: 'Square' },
{ value: 'saw', label: 'Saw' },
{ value: 'triangle', label: 'Tri' }
]
},
{
id: 'ringFreq',
label: 'Freq',
min: 0.1,
max: 1000,
default: 200,
step: 0.1,
unit: 'Hz'
},
{
id: 'ringSpread',
label: 'Spread',
min: 0,
max: 100,
default: 0,
step: 1,
unit: '%'
}
]
},
{
id: 'chorus',
name: 'Chorus',
bypassable: true,
parameters: [
{
id: 'chorusType',
label: 'Type',
min: 0,
max: 0,
default: 'chorus',
step: 1,
unit: '',
options: [
{ value: 'chorus', label: 'Chorus' },
{ value: 'flanger', label: 'Flanger' }
]
},
{
id: 'chorusRate',
label: 'Rate',
min: 0.1,
max: 10,
default: 0.5,
step: 0.1,
unit: 'Hz'
},
{
id: 'chorusDepth',
label: 'Depth',
min: 0,
max: 100,
default: 50,
step: 1,
unit: '%'
},
{
id: 'chorusFeedback',
label: 'Feedback',
min: 0,
max: 100,
default: 0,
step: 1,
unit: '%'
},
{
id: 'chorusSpread',
label: 'Spread',
min: 0,
max: 100,
default: 30,
step: 1,
unit: '%'
},
{
id: 'chorusMix',
label: 'Mix',
min: 0,
max: 100,
default: 50,
step: 1,
unit: '%'
}
]
},
{
id: 'filter',
name: 'Filter',
@ -150,7 +255,7 @@ export const EFFECTS: ParameterGroup[] = [
{
id: 'filterRes',
label: 'Res',
min: 0.5,
min: 0.05,
max: 10,
default: 0.707,
step: 0.1,
@ -160,7 +265,7 @@ export const EFFECTS: ParameterGroup[] = [
},
{
id: 'foldcrush',
name: 'Fold and Crush',
name: 'Distortion',
bypassable: true,
parameters: [
{
@ -172,34 +277,43 @@ export const EFFECTS: ParameterGroup[] = [
step: 1,
unit: '',
options: [
{ value: 'tube', label: 'Tube' },
{ value: 'tape', label: 'Tape' },
{ value: 'fuzz', label: 'Fuzz' },
{ value: 'fold', label: 'Fold' },
{ value: 'soft', label: 'Soft' },
{ value: 'cubic', label: 'Cubic' },
{ value: 'diode', label: 'Diode' },
{ value: 'hard', label: 'Hard' }
{ value: 'crush', label: 'Crush' }
]
},
{
id: 'wavefolderDrive',
label: 'Drive',
min: 0.001,
min: 0,
max: 10,
default: 1,
default: 0,
step: 0.1,
unit: ''
},
{
id: 'bitcrushDepth',
label: 'Depth',
label: 'Bits',
min: 1,
max: 16,
default: 16,
step: 1,
unit: 'bit'
unit: ''
},
{
id: 'bitcrushRate',
label: 'Rate',
label: 'Downsample',
min: 0,
max: 100,
default: 0,
step: 1,
unit: '%'
},
{
id: 'glitchAmount',
label: 'Glitch',
min: 0,
max: 100,
default: 0,
@ -276,7 +390,7 @@ export const EFFECTS: ParameterGroup[] = [
parameters: [
{
id: 'reverbWetDry',
label: 'Amount',
label: 'Mix',
min: 0,
max: 100,
default: 0,
@ -284,17 +398,8 @@ export const EFFECTS: ParameterGroup[] = [
unit: '%'
},
{
id: 'reverbDecay',
label: 'Decay',
min: 0.1,
max: 5,
default: 2,
step: 0.1,
unit: 's'
},
{
id: 'reverbDamping',
label: 'Damping',
id: 'reverbSize',
label: 'Size',
min: 0,
max: 100,
default: 50,
@ -302,17 +407,17 @@ export const EFFECTS: ParameterGroup[] = [
unit: '%'
},
{
id: 'reverbPanRate',
label: 'Pan Rate',
min: 0,
max: 10,
default: 0,
step: 0.1,
unit: 'Hz'
id: 'reverbDecay',
label: 'Decay',
min: 10,
max: 95,
default: 70,
step: 1,
unit: '%'
},
{
id: 'reverbPanWidth',
label: 'Pan Width',
id: 'reverbDamping',
label: 'Damping',
min: 0,
max: 100,
default: 50,

View File

@ -74,6 +74,8 @@ export class AudioPlayer {
context.audioWorklet.addModule('/worklets/fold-crush-processor.js'),
context.audioWorklet.addModule('/worklets/bytebeat-processor.js'),
context.audioWorklet.addModule('/worklets/fm-processor.js'),
context.audioWorklet.addModule('/worklets/ring-mod-processor.js'),
context.audioWorklet.addModule('/worklets/chorus-processor.js'),
context.audioWorklet.addModule('/worklets/output-limiter.js')
])
this.workletRegistered = true

View File

@ -0,0 +1,102 @@
import type { Effect } from './Effect.interface'
export class ChorusEffect implements Effect {
readonly id = 'chorus'
private inputNode: GainNode
private outputNode: GainNode
private processorNode: AudioWorkletNode | null = null
private wetNode: GainNode
private dryNode: GainNode
private bypassed: boolean = false
private currentWetValue: number = 0.5
private currentDryValue: number = 0.5
constructor(audioContext: AudioContext) {
this.inputNode = audioContext.createGain()
this.outputNode = audioContext.createGain()
this.wetNode = audioContext.createGain()
this.dryNode = audioContext.createGain()
this.wetNode.gain.value = 0.5
this.dryNode.gain.value = 0.5
this.inputNode.connect(this.dryNode)
this.dryNode.connect(this.outputNode)
}
async initialize(audioContext: AudioContext): Promise<void> {
this.processorNode = new AudioWorkletNode(audioContext, 'chorus-processor', {
numberOfInputs: 1,
numberOfOutputs: 1,
outputChannelCount: [2]
})
this.processorNode.port.postMessage({ type: 'bypass', value: true })
this.inputNode.connect(this.processorNode)
this.processorNode.connect(this.wetNode)
this.wetNode.connect(this.outputNode)
}
getInputNode(): AudioNode {
return this.inputNode
}
getOutputNode(): AudioNode {
return this.outputNode
}
setBypass(bypass: boolean): void {
this.bypassed = bypass
if (this.processorNode) {
this.processorNode.port.postMessage({ type: 'bypass', value: bypass })
}
if (bypass) {
this.wetNode.gain.value = 0
this.dryNode.gain.value = 1
} else {
this.wetNode.gain.value = this.currentWetValue
this.dryNode.gain.value = this.currentDryValue
}
}
updateParams(values: Record<string, number | string>): void {
if (!this.processorNode) return
if (values.chorusType !== undefined) {
this.processorNode.port.postMessage({ type: 'mode', value: values.chorusType })
}
if (values.chorusRate !== undefined) {
this.processorNode.port.postMessage({ type: 'frequency', value: values.chorusRate })
}
if (values.chorusDepth !== undefined && typeof values.chorusDepth === 'number') {
this.processorNode.port.postMessage({ type: 'depth', value: values.chorusDepth / 100 })
}
if (values.chorusFeedback !== undefined && typeof values.chorusFeedback === 'number') {
this.processorNode.port.postMessage({ type: 'feedback', value: values.chorusFeedback / 100 })
}
if (values.chorusSpread !== undefined && typeof values.chorusSpread === 'number') {
this.processorNode.port.postMessage({ type: 'spread', value: values.chorusSpread / 100 })
}
if (values.chorusMix !== undefined && typeof values.chorusMix === 'number') {
const wet = values.chorusMix / 100
this.currentWetValue = wet
this.currentDryValue = 1 - wet
if (!this.bypassed) {
this.wetNode.gain.value = this.currentWetValue
this.dryNode.gain.value = this.currentDryValue
}
}
}
dispose(): void {
if (this.processorNode) {
this.processorNode.disconnect()
}
this.wetNode.disconnect()
this.dryNode.disconnect()
this.inputNode.disconnect()
this.outputNode.disconnect()
}
}

View File

@ -2,6 +2,8 @@ import type { Effect } from './Effect.interface'
import { FilterEffect } from './FilterEffect'
import { FoldCrushEffect } from './FoldCrushEffect'
import { DelayEffect } from './DelayEffect'
import { RingModEffect } from './RingModEffect'
import { ChorusEffect } from './ChorusEffect'
import { ReverbEffect } from './ReverbEffect'
import { OutputLimiter } from './OutputLimiter'
@ -12,6 +14,8 @@ export class EffectsChain {
private effects: Effect[]
private filterEffect: FilterEffect
private foldCrushEffect: FoldCrushEffect
private ringModEffect: RingModEffect
private chorusEffect: ChorusEffect
private outputLimiter: OutputLimiter
constructor(audioContext: AudioContext) {
@ -21,9 +25,13 @@ export class EffectsChain {
this.filterEffect = new FilterEffect(audioContext)
this.foldCrushEffect = new FoldCrushEffect(audioContext)
this.ringModEffect = new RingModEffect(audioContext)
this.chorusEffect = new ChorusEffect(audioContext)
this.outputLimiter = new OutputLimiter(audioContext)
this.effects = [
this.ringModEffect,
this.chorusEffect,
this.filterEffect,
this.foldCrushEffect,
new DelayEffect(audioContext),
@ -38,6 +46,8 @@ export class EffectsChain {
await Promise.all([
this.filterEffect.initialize(audioContext),
this.foldCrushEffect.initialize(audioContext),
this.ringModEffect.initialize(audioContext),
this.chorusEffect.initialize(audioContext),
this.outputLimiter.initialize(audioContext)
])
}

View File

@ -62,6 +62,9 @@ export class FoldCrushEffect implements Effect {
if (values.bitcrushRate !== undefined) {
this.processorNode.port.postMessage({ type: 'crushAmount', value: values.bitcrushRate })
}
if (values.glitchAmount !== undefined) {
this.processorNode.port.postMessage({ type: 'glitchAmount', value: values.glitchAmount })
}
}
dispose(): void {

View File

@ -8,202 +8,144 @@ export class ReverbEffect implements Effect {
private outputNode: GainNode
private wetNode: GainNode
private dryNode: GainNode
private mixNode: GainNode
private pannerNode: StereoPannerNode
private panLfoNode: OscillatorNode
private panLfoGainNode: GainNode
private convolverA: ConvolverNode
private convolverB: ConvolverNode
private gainA: GainNode
private gainB: GainNode
private activeConvolver: 'A' | 'B' = 'A'
private bypassed: boolean = false
private currentWetValue: number = 0
private currentDryValue: number = 1
private currentDecay: number = 0.5
private currentDecay: number = 0.7
private currentDamping: number = 0.5
private currentSize: number = 0.5
private earlyReflectionsNode: GainNode
private earlyReflectionDelays: DelayNode[] = []
private earlyReflectionGains: GainNode[] = []
private earlyReflectionFilters: BiquadFilterNode[] = []
private lowBandSplitter: BiquadFilterNode
private midBandLowPass: BiquadFilterNode
private midBandHighPass: BiquadFilterNode
private highBandSplitter: BiquadFilterNode
private lowBandProcessor: BandProcessor
private midBandProcessor: BandProcessor
private highBandProcessor: BandProcessor
private lowEnvFollower: DynamicsCompressorNode
private midEnvFollower: DynamicsCompressorNode
private highEnvFollower: DynamicsCompressorNode
private lowToHighModGain: GainNode
private highToLowModGain: GainNode
private midToGlobalModGain: GainNode
private bandMixer: GainNode
private pendingDecay: number = 0.7
private pendingDamping: number = 0.5
private pendingSize: number = 0.5
private debounceTimer: number | null = null
private readonly DEBOUNCE_MS = 250
constructor(audioContext: AudioContext) {
this.audioContext = audioContext
const sr = audioContext.sampleRate
this.inputNode = audioContext.createGain()
this.outputNode = audioContext.createGain()
this.mixNode = audioContext.createGain()
this.wetNode = audioContext.createGain()
this.dryNode = audioContext.createGain()
this.pannerNode = audioContext.createStereoPanner()
this.panLfoNode = audioContext.createOscillator()
this.panLfoGainNode = audioContext.createGain()
this.convolverA = audioContext.createConvolver()
this.convolverB = audioContext.createConvolver()
this.gainA = audioContext.createGain()
this.gainB = audioContext.createGain()
this.wetNode.gain.value = 0
this.dryNode.gain.value = 1
this.panLfoNode.frequency.value = 0
this.panLfoGainNode.gain.value = 0
this.panLfoNode.connect(this.panLfoGainNode)
this.panLfoGainNode.connect(this.pannerNode.pan)
this.panLfoNode.start()
this.earlyReflectionsNode = audioContext.createGain()
this.buildEarlyReflections(sr)
this.lowBandSplitter = audioContext.createBiquadFilter()
this.lowBandSplitter.type = 'lowpass'
this.lowBandSplitter.frequency.value = 250
this.lowBandSplitter.Q.value = 0.707
this.midBandHighPass = audioContext.createBiquadFilter()
this.midBandHighPass.type = 'highpass'
this.midBandHighPass.frequency.value = 250
this.midBandHighPass.Q.value = 0.707
this.midBandLowPass = audioContext.createBiquadFilter()
this.midBandLowPass.type = 'lowpass'
this.midBandLowPass.frequency.value = 2500
this.midBandLowPass.Q.value = 0.707
this.highBandSplitter = audioContext.createBiquadFilter()
this.highBandSplitter.type = 'highpass'
this.highBandSplitter.frequency.value = 2500
this.highBandSplitter.Q.value = 0.707
this.lowBandProcessor = new BandProcessor(audioContext, 'low', sr)
this.midBandProcessor = new BandProcessor(audioContext, 'mid', sr)
this.highBandProcessor = new BandProcessor(audioContext, 'high', sr)
this.lowEnvFollower = audioContext.createDynamicsCompressor()
this.lowEnvFollower.threshold.value = -50
this.lowEnvFollower.knee.value = 40
this.lowEnvFollower.ratio.value = 12
this.lowEnvFollower.attack.value = 0.003
this.lowEnvFollower.release.value = 0.25
this.midEnvFollower = audioContext.createDynamicsCompressor()
this.midEnvFollower.threshold.value = -50
this.midEnvFollower.knee.value = 40
this.midEnvFollower.ratio.value = 12
this.midEnvFollower.attack.value = 0.003
this.midEnvFollower.release.value = 0.25
this.highEnvFollower = audioContext.createDynamicsCompressor()
this.highEnvFollower.threshold.value = -50
this.highEnvFollower.knee.value = 40
this.highEnvFollower.ratio.value = 12
this.highEnvFollower.attack.value = 0.001
this.highEnvFollower.release.value = 0.1
this.lowToHighModGain = audioContext.createGain()
this.highToLowModGain = audioContext.createGain()
this.midToGlobalModGain = audioContext.createGain()
this.bandMixer = audioContext.createGain()
this.buildGraph()
this.updateDecayAndDamping()
this.gainA.gain.value = 1
this.gainB.gain.value = 0
this.inputNode.connect(this.dryNode)
this.dryNode.connect(this.mixNode)
this.wetNode.connect(this.mixNode)
this.mixNode.connect(this.pannerNode)
this.pannerNode.connect(this.outputNode)
this.dryNode.connect(this.outputNode)
this.inputNode.connect(this.convolverA)
this.convolverA.connect(this.gainA)
this.gainA.connect(this.wetNode)
this.inputNode.connect(this.convolverB)
this.convolverB.connect(this.gainB)
this.gainB.connect(this.wetNode)
this.wetNode.connect(this.outputNode)
this.generateImpulseResponse('A', this.currentDecay, this.currentDamping, this.currentSize)
}
private buildEarlyReflections(sr: number): void {
const primes = [17, 29, 41, 59, 71, 97, 113, 127]
const scale = sr / 48000
private generateImpulseResponse(target: 'A' | 'B', decay: number, damping: number, size: number): void {
const sampleRate = this.audioContext.sampleRate
const decayTime = 0.5 + decay * 3.5
const length = Math.floor(sampleRate * decayTime)
for (let i = 0; i < primes.length; i++) {
const delay = this.audioContext.createDelay(0.2)
delay.delayTime.value = (primes[i] * scale) / 1000
const impulse = this.audioContext.createBuffer(2, length, sampleRate)
const leftChannel = impulse.getChannelData(0)
const rightChannel = impulse.getChannelData(1)
const gain = this.audioContext.createGain()
gain.gain.value = 0.7 * Math.pow(0.85, i)
const fadeInSamples = Math.floor(0.001 * sampleRate * (0.5 + size * 1.5))
const filter = this.audioContext.createBiquadFilter()
filter.type = i % 2 === 0 ? 'lowpass' : 'highshelf'
filter.frequency.value = 3000 + i * 500
filter.gain.value = -2 * i
const dampingFreq = 1000 + damping * 8000
const dampingCoeff = Math.exp(-2 * Math.PI * dampingFreq / sampleRate)
this.earlyReflectionDelays.push(delay)
this.earlyReflectionGains.push(gain)
this.earlyReflectionFilters.push(filter)
let leftLPState = 0
let rightLPState = 0
for (let i = 0; i < length; i++) {
const decayValue = Math.exp(-3 * i / length / decay)
const fadeIn = i < fadeInSamples ? i / fadeInSamples : 1.0
const leftNoise = (Math.random() * 2 - 1) * decayValue * fadeIn
const rightNoise = (Math.random() * 2 - 1) * decayValue * fadeIn
const dampingAmount = Math.min(1, i / (length * 0.3))
const currentDampingCoeff = 1 - dampingAmount * (1 - dampingCoeff)
leftLPState = leftLPState * currentDampingCoeff + leftNoise * (1 - currentDampingCoeff)
rightLPState = rightLPState * currentDampingCoeff + rightNoise * (1 - currentDampingCoeff)
leftChannel[i] = leftLPState * 0.5
rightChannel[i] = rightLPState * 0.5
}
if (target === 'A') {
this.convolverA.buffer = impulse
} else {
this.convolverB.buffer = impulse
}
}
private buildGraph(): void {
this.inputNode.connect(this.earlyReflectionsNode)
private crossfadeToStandby(): void {
const now = this.audioContext.currentTime
const crossfadeDuration = 0.02
for (let i = 0; i < this.earlyReflectionDelays.length; i++) {
this.earlyReflectionsNode.connect(this.earlyReflectionDelays[i])
this.earlyReflectionDelays[i].connect(this.earlyReflectionFilters[i])
this.earlyReflectionFilters[i].connect(this.earlyReflectionGains[i])
this.earlyReflectionGains[i].connect(this.wetNode)
if (this.activeConvolver === 'A') {
this.gainA.gain.setValueAtTime(1, now)
this.gainA.gain.exponentialRampToValueAtTime(0.001, now + crossfadeDuration)
this.gainB.gain.setValueAtTime(0.001, now)
this.gainB.gain.exponentialRampToValueAtTime(1, now + crossfadeDuration)
this.activeConvolver = 'B'
} else {
this.gainB.gain.setValueAtTime(1, now)
this.gainB.gain.exponentialRampToValueAtTime(0.001, now + crossfadeDuration)
this.gainA.gain.setValueAtTime(0.001, now)
this.gainA.gain.exponentialRampToValueAtTime(1, now + crossfadeDuration)
this.activeConvolver = 'A'
}
this.earlyReflectionsNode.connect(this.lowBandSplitter)
this.earlyReflectionsNode.connect(this.midBandHighPass)
this.earlyReflectionsNode.connect(this.highBandSplitter)
this.midBandHighPass.connect(this.midBandLowPass)
this.lowBandSplitter.connect(this.lowBandProcessor.getInputNode())
this.midBandLowPass.connect(this.midBandProcessor.getInputNode())
this.highBandSplitter.connect(this.highBandProcessor.getInputNode())
this.lowBandProcessor.getOutputNode().connect(this.lowEnvFollower)
this.midBandProcessor.getOutputNode().connect(this.midEnvFollower)
this.highBandProcessor.getOutputNode().connect(this.highEnvFollower)
this.lowEnvFollower.connect(this.lowToHighModGain)
this.highEnvFollower.connect(this.highToLowModGain)
this.midEnvFollower.connect(this.midToGlobalModGain)
this.lowToHighModGain.connect(this.highBandProcessor.getModulationTarget())
this.highToLowModGain.connect(this.lowBandProcessor.getModulationTarget())
this.lowBandProcessor.getOutputNode().connect(this.bandMixer)
this.midBandProcessor.getOutputNode().connect(this.bandMixer)
this.highBandProcessor.getOutputNode().connect(this.bandMixer)
this.bandMixer.connect(this.wetNode)
}
private updateDecayAndDamping(): void {
const decay = this.currentDecay
const damping = this.currentDamping
private scheduleRegeneration(): void {
if (this.debounceTimer !== null) {
clearTimeout(this.debounceTimer)
}
this.lowBandProcessor.setDecay(decay * 1.2)
this.midBandProcessor.setDecay(decay)
this.highBandProcessor.setDecay(decay * 0.6)
this.debounceTimer = window.setTimeout(() => {
this.currentDecay = this.pendingDecay
this.currentDamping = this.pendingDamping
this.currentSize = this.pendingSize
this.lowBandProcessor.setDamping(damping * 0.5)
this.midBandProcessor.setDamping(damping)
this.highBandProcessor.setDamping(damping * 1.5)
const standbyConvolver = this.activeConvolver === 'A' ? 'B' : 'A'
this.generateImpulseResponse(standbyConvolver, this.currentDecay, this.currentDamping, this.currentSize)
const modAmount = 0.3
this.lowToHighModGain.gain.value = modAmount
this.highToLowModGain.gain.value = modAmount * 0.7
this.midToGlobalModGain.gain.value = modAmount * 0.5
setTimeout(() => {
this.crossfadeToStandby()
}, 10)
this.debounceTimer = null
}, this.DEBOUNCE_MS)
}
getInputNode(): AudioNode {
@ -226,16 +168,21 @@ export class ReverbEffect implements Effect {
}
updateParams(values: Record<string, number | string>): void {
let needsUpdate = false
let needsRegenerate = false
if (values.reverbDecay !== undefined && typeof values.reverbDecay === 'number') {
this.currentDecay = values.reverbDecay / 100
needsUpdate = true
this.pendingDecay = values.reverbDecay / 100
needsRegenerate = true
}
if (values.reverbDamping !== undefined && typeof values.reverbDamping === 'number') {
this.currentDamping = values.reverbDamping / 100
needsUpdate = true
this.pendingDamping = values.reverbDamping / 100
needsRegenerate = true
}
if (values.reverbSize !== undefined && typeof values.reverbSize === 'number') {
this.pendingSize = values.reverbSize / 100
needsRegenerate = true
}
if (values.reverbWetDry !== undefined && typeof values.reverbWetDry === 'number') {
@ -249,303 +196,23 @@ export class ReverbEffect implements Effect {
}
}
if (values.reverbPanRate !== undefined && typeof values.reverbPanRate === 'number') {
const rate = values.reverbPanRate
this.panLfoNode.frequency.setTargetAtTime(
rate,
this.audioContext.currentTime,
0.01
)
}
if (values.reverbPanWidth !== undefined && typeof values.reverbPanWidth === 'number') {
const width = values.reverbPanWidth / 100
this.panLfoGainNode.gain.setTargetAtTime(
width,
this.audioContext.currentTime,
0.01
)
}
if (needsUpdate) {
this.updateDecayAndDamping()
if (needsRegenerate) {
this.scheduleRegeneration()
}
}
dispose(): void {
this.panLfoNode.stop()
this.panLfoNode.disconnect()
this.panLfoGainNode.disconnect()
if (this.debounceTimer !== null) {
clearTimeout(this.debounceTimer)
}
this.inputNode.disconnect()
this.outputNode.disconnect()
this.mixNode.disconnect()
this.wetNode.disconnect()
this.dryNode.disconnect()
this.pannerNode.disconnect()
this.earlyReflectionsNode.disconnect()
this.earlyReflectionDelays.forEach(d => d.disconnect())
this.earlyReflectionGains.forEach(g => g.disconnect())
this.earlyReflectionFilters.forEach(f => f.disconnect())
this.lowBandSplitter.disconnect()
this.midBandHighPass.disconnect()
this.midBandLowPass.disconnect()
this.highBandSplitter.disconnect()
this.lowBandProcessor.dispose()
this.midBandProcessor.dispose()
this.highBandProcessor.dispose()
this.lowEnvFollower.disconnect()
this.midEnvFollower.disconnect()
this.highEnvFollower.disconnect()
this.lowToHighModGain.disconnect()
this.highToLowModGain.disconnect()
this.midToGlobalModGain.disconnect()
this.bandMixer.disconnect()
}
}
class BandProcessor {
private audioContext: AudioContext
private bandType: 'low' | 'mid' | 'high'
private inputNode: GainNode
private outputNode: GainNode
private modulationTarget: GainNode
private delay1: DelayNode
private delay2: DelayNode
private allpass1: DelayNode
private allpass2: DelayNode
private ap1Gain: GainNode
private ap2Gain: GainNode
private filter1: BiquadFilterNode
private filter2: BiquadFilterNode
private filter3: BiquadFilterNode
private feedbackGain: GainNode
private saturation: WaveShaperNode
private feedbackMixer: GainNode
constructor(audioContext: AudioContext, bandType: 'low' | 'mid' | 'high', sr: number) {
this.audioContext = audioContext
this.bandType = bandType
this.inputNode = audioContext.createGain()
this.outputNode = audioContext.createGain()
this.modulationTarget = audioContext.createGain()
this.modulationTarget.gain.value = 0
const scale = sr / 48000
const delayTimes = this.getDelayTimes(bandType, scale, sr)
this.delay1 = audioContext.createDelay(1.0)
this.delay2 = audioContext.createDelay(1.0)
this.delay1.delayTime.value = delayTimes.d1
this.delay2.delayTime.value = delayTimes.d2
this.allpass1 = audioContext.createDelay(0.1)
this.allpass2 = audioContext.createDelay(0.1)
this.allpass1.delayTime.value = delayTimes.ap1
this.allpass2.delayTime.value = delayTimes.ap2
this.ap1Gain = audioContext.createGain()
this.ap2Gain = audioContext.createGain()
this.ap1Gain.gain.value = 0.7
this.ap2Gain.gain.value = 0.7
this.filter1 = audioContext.createBiquadFilter()
this.filter2 = audioContext.createBiquadFilter()
this.filter3 = audioContext.createBiquadFilter()
this.setupFilters(bandType)
this.feedbackGain = audioContext.createGain()
this.feedbackGain.gain.value = 0.5
this.saturation = audioContext.createWaveShaper()
this.saturation.curve = this.createSaturationCurve(bandType)
this.saturation.oversample = '2x'
this.feedbackMixer = audioContext.createGain()
this.buildGraph()
}
private getDelayTimes(bandType: string, scale: number, sr: number) {
const times: Record<string, { d1: number; d2: number; ap1: number; ap2: number }> = {
low: {
d1: (1201 * scale) / sr,
d2: (6171 * scale) / sr,
ap1: (2333 * scale) / sr,
ap2: (4513 * scale) / sr,
},
mid: {
d1: (907 * scale) / sr,
d2: (4217 * scale) / sr,
ap1: (1801 * scale) / sr,
ap2: (3119 * scale) / sr,
},
high: {
d1: (503 * scale) / sr,
d2: (2153 * scale) / sr,
ap1: (907 * scale) / sr,
ap2: (1453 * scale) / sr,
},
}
return times[bandType]
}
private setupFilters(bandType: string): void {
if (bandType === 'low') {
this.filter1.type = 'lowpass'
this.filter1.frequency.value = 1200
this.filter1.Q.value = 0.707
this.filter2.type = 'lowshelf'
this.filter2.frequency.value = 200
this.filter2.gain.value = 2
this.filter3.type = 'peaking'
this.filter3.frequency.value = 600
this.filter3.Q.value = 1.0
this.filter3.gain.value = -3
} else if (bandType === 'mid') {
this.filter1.type = 'lowpass'
this.filter1.frequency.value = 5000
this.filter1.Q.value = 0.707
this.filter2.type = 'peaking'
this.filter2.frequency.value = 1200
this.filter2.Q.value = 1.5
this.filter2.gain.value = -2
this.filter3.type = 'highshelf'
this.filter3.frequency.value = 3000
this.filter3.gain.value = -4
} else {
this.filter1.type = 'lowpass'
this.filter1.frequency.value = 12000
this.filter1.Q.value = 0.5
this.filter2.type = 'lowpass'
this.filter2.frequency.value = 8000
this.filter2.Q.value = 0.707
this.filter3.type = 'highshelf'
this.filter3.frequency.value = 5000
this.filter3.gain.value = -6
}
}
private createSaturationCurve(bandType: string): Float32Array {
const samples = 4096
const curve = new Float32Array(samples)
const amount = bandType === 'low' ? 0.8 : bandType === 'mid' ? 0.5 : 0.3
for (let i = 0; i < samples; i++) {
const x = (i * 2) / samples - 1
curve[i] = Math.tanh(x * (1 + amount)) / (1 + amount * 0.5)
}
return curve
}
private buildGraph(): void {
this.inputNode.connect(this.delay1)
this.delay1.connect(this.filter1)
this.filter1.connect(this.filter2)
this.filter2.connect(this.filter3)
this.filter3.connect(this.delay2)
const ap1Out = this.createAllPass(this.delay2, this.allpass1, this.ap1Gain)
const ap2Out = this.createAllPass(ap1Out, this.allpass2, this.ap2Gain)
ap2Out.connect(this.feedbackGain)
this.feedbackGain.connect(this.saturation)
this.saturation.connect(this.feedbackMixer)
this.modulationTarget.connect(this.feedbackMixer)
this.feedbackMixer.connect(this.inputNode)
ap2Out.connect(this.outputNode)
}
private createAllPass(input: AudioNode, delay: DelayNode, gain: GainNode): AudioNode {
const output = this.audioContext.createGain()
const feedbackGain = this.audioContext.createGain()
feedbackGain.gain.value = -1
input.connect(delay)
input.connect(feedbackGain)
feedbackGain.connect(output)
delay.connect(gain)
gain.connect(output)
gain.connect(input)
return output
}
getInputNode(): AudioNode {
return this.inputNode
}
getOutputNode(): AudioNode {
return this.outputNode
}
getModulationTarget(): AudioNode {
return this.modulationTarget
}
setDecay(decay: number): void {
this.feedbackGain.gain.setTargetAtTime(
Math.min(0.95, decay),
this.audioContext.currentTime,
0.01
)
}
setDamping(damping: number): void {
let cutoff: number
if (this.bandType === 'low') {
cutoff = 500 + damping * 1500
} else if (this.bandType === 'mid') {
cutoff = 2000 + damping * 6000
} else {
cutoff = 4000 + damping * 10000
}
this.filter1.frequency.setTargetAtTime(
cutoff,
this.audioContext.currentTime,
0.01
)
}
dispose(): void {
this.inputNode.disconnect()
this.outputNode.disconnect()
this.modulationTarget.disconnect()
this.delay1.disconnect()
this.delay2.disconnect()
this.allpass1.disconnect()
this.allpass2.disconnect()
this.ap1Gain.disconnect()
this.ap2Gain.disconnect()
this.filter1.disconnect()
this.filter2.disconnect()
this.filter3.disconnect()
this.feedbackGain.disconnect()
this.saturation.disconnect()
this.feedbackMixer.disconnect()
this.convolverA.disconnect()
this.convolverB.disconnect()
this.gainA.disconnect()
this.gainB.disconnect()
}
}

View File

@ -0,0 +1,61 @@
import type { Effect } from './Effect.interface'
export class RingModEffect implements Effect {
readonly id = 'ring'
private inputNode: GainNode
private outputNode: GainNode
private processorNode: AudioWorkletNode | null = null
constructor(audioContext: AudioContext) {
this.inputNode = audioContext.createGain()
this.outputNode = audioContext.createGain()
}
async initialize(audioContext: AudioContext): Promise<void> {
this.processorNode = new AudioWorkletNode(audioContext, 'ring-mod-processor', {
numberOfInputs: 1,
numberOfOutputs: 1,
outputChannelCount: [2]
})
this.processorNode.port.postMessage({ type: 'bypass', value: true })
this.inputNode.connect(this.processorNode)
this.processorNode.connect(this.outputNode)
}
getInputNode(): AudioNode {
return this.inputNode
}
getOutputNode(): AudioNode {
return this.outputNode
}
setBypass(bypass: boolean): void {
if (this.processorNode) {
this.processorNode.port.postMessage({ type: 'bypass', value: bypass })
}
}
updateParams(values: Record<string, number | string>): void {
if (!this.processorNode) return
if (values.ringFreq !== undefined) {
this.processorNode.port.postMessage({ type: 'frequency', value: values.ringFreq })
}
if (values.ringShape !== undefined) {
this.processorNode.port.postMessage({ type: 'shape', value: values.ringShape })
}
if (values.ringSpread !== undefined && typeof values.ringSpread === 'number') {
this.processorNode.port.postMessage({ type: 'spread', value: values.ringSpread / 100 })
}
}
dispose(): void {
if (this.processorNode) {
this.processorNode.disconnect()
}
this.inputNode.disconnect()
this.outputNode.disconnect()
}
}

View File

@ -38,7 +38,7 @@ export function generateRandomFMPatch(complexity: number = 1): FMPatchConfig {
]
const pitchLFO = {
waveform: Math.floor(Math.random() * 4),
waveform: Math.floor(Math.random() * 8),
depth: Math.random() < 0.4 ? 0.03 + Math.random() * 0.22 : 0,
baseRate: 0.1 + Math.random() * 9.9
}