diff --git a/node_modules/.vite/deps/_metadata.json b/node_modules/.vite/deps/_metadata.json
index d216091e..6cfee876 100644
--- a/node_modules/.vite/deps/_metadata.json
+++ b/node_modules/.vite/deps/_metadata.json
@@ -1,61 +1,61 @@
{
- "hash": "43216e8d",
- "configHash": "0b9ed782",
+ "hash": "480f37ab",
+ "configHash": "e652d8ff",
"lockfileHash": "dadb379e",
- "browserHash": "4f0bb7c4",
+ "browserHash": "55e2ae32",
"optimized": {
"@nanostores/persistent": {
"src": "../../.pnpm/@nanostores+persistent@1.1.0_nanostores@1.0.1/node_modules/@nanostores/persistent/index.js",
"file": "@nanostores_persistent.js",
- "fileHash": "7596f610",
+ "fileHash": "41270c63",
"needsInterop": false
},
"@nanostores/react": {
"src": "../../.pnpm/@nanostores+react@1.0.0_nanostores@1.0.1_react@19.1.1/node_modules/@nanostores/react/index.js",
"file": "@nanostores_react.js",
- "fileHash": "19e1624e",
+ "fileHash": "125eb5ad",
"needsInterop": false
},
"jszip": {
"src": "../../.pnpm/jszip@3.10.1/node_modules/jszip/dist/jszip.min.js",
"file": "jszip.js",
- "fileHash": "e59e7578",
+ "fileHash": "ea14d80a",
"needsInterop": true
},
"lucide-react": {
"src": "../../.pnpm/lucide-react@0.544.0_react@19.1.1/node_modules/lucide-react/dist/esm/lucide-react.js",
"file": "lucide-react.js",
- "fileHash": "f256b1cf",
+ "fileHash": "347cd426",
"needsInterop": false
},
"react-dom": {
"src": "../../.pnpm/react-dom@19.1.1_react@19.1.1/node_modules/react-dom/index.js",
"file": "react-dom.js",
- "fileHash": "74c66a5f",
+ "fileHash": "67fe1167",
"needsInterop": true
},
"react-dom/client": {
"src": "../../.pnpm/react-dom@19.1.1_react@19.1.1/node_modules/react-dom/client.js",
"file": "react-dom_client.js",
- "fileHash": "41baaeaa",
+ "fileHash": "9c5fd5d6",
"needsInterop": true
},
"react": {
"src": "../../.pnpm/react@19.1.1/node_modules/react/index.js",
"file": "react.js",
- "fileHash": "37acbd53",
+ "fileHash": "35a8de87",
"needsInterop": true
},
"react/jsx-dev-runtime": {
"src": "../../.pnpm/react@19.1.1/node_modules/react/jsx-dev-runtime.js",
"file": "react_jsx-dev-runtime.js",
- "fileHash": "bf9c5ec3",
+ "fileHash": "675ff029",
"needsInterop": true
},
"react/jsx-runtime": {
"src": "../../.pnpm/react@19.1.1/node_modules/react/jsx-runtime.js",
"file": "react_jsx-runtime.js",
- "fileHash": "082ac5fc",
+ "fileHash": "4bc2e93d",
"needsInterop": true
}
},
diff --git a/src/App.tsx b/src/App.tsx
index bc2fc0da..4f6889a0 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -114,8 +114,8 @@ function App() {
}
}
- const handleEffectChange = (parameterId: string, value: number) => {
- effectSettings.setKey(parameterId as keyof typeof effectValues, value)
+ const handleEffectChange = (parameterId: string, value: number | boolean) => {
+ effectSettings.setKey(parameterId as any, value as any)
if (playbackManagerRef.current) {
playbackManagerRef.current.setEffects(effectValues)
}
diff --git a/src/components/EffectsBar.tsx b/src/components/EffectsBar.tsx
index 8a3dc86a..7207c1f2 100644
--- a/src/components/EffectsBar.tsx
+++ b/src/components/EffectsBar.tsx
@@ -1,11 +1,12 @@
import { Slider } from './Slider'
+import { Switch } from './Switch'
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) => void
+ onChange: (parameterId: string, value: number | boolean) => void
}
export function EffectsBar({ values, onChange }: EffectsBarProps) {
@@ -18,23 +19,39 @@ export function EffectsBar({ values, onChange }: EffectsBarProps) {
return (
-
- {EFFECTS.flatMap(effect =>
- effect.parameters.map(param => (
-
onChange(param.id, value)}
- formatValue={param.id === 'clipMode' ? formatValue : undefined}
- valueId={param.id}
- />
- ))
- )}
+
+ {EFFECTS.map(effect => (
+
+
+
+ {effect.name.toUpperCase()}
+
+ {effect.bypassable && (
+ onChange(`${effect.id}Bypass`, !checked)}
+ label={Boolean(values[`${effect.id}Bypass`]) ? 'OFF' : 'ON'}
+ />
+ )}
+
+
+ {effect.parameters.map(param => (
+ onChange(param.id, value)}
+ formatValue={param.id === 'clipMode' ? formatValue : undefined}
+ valueId={param.id}
+ />
+ ))}
+
+
+ ))}
)
diff --git a/src/components/EngineControls.tsx b/src/components/EngineControls.tsx
index 20109295..36e4c0b6 100644
--- a/src/components/EngineControls.tsx
+++ b/src/components/EngineControls.tsx
@@ -31,7 +31,7 @@ export function EngineControls({ values, onChange }: EngineControlsProps) {
{param.label.toUpperCase()}
- {formatValue(param.id, values[param.id] ?? param.default)}
+ {formatValue(param.id, (values[param.id] as number) ?? param.default)}
onChange(param.id, Number(e.target.value))}
className="w-full h-[2px] bg-white appearance-none cursor-pointer"
/>
diff --git a/src/components/Switch.tsx b/src/components/Switch.tsx
new file mode 100644
index 00000000..1b5df511
--- /dev/null
+++ b/src/components/Switch.tsx
@@ -0,0 +1,29 @@
+interface SwitchProps {
+ checked: boolean
+ onChange: (checked: boolean) => void
+ label?: string
+}
+
+export function Switch({ checked, onChange, label }: SwitchProps) {
+ return (
+
+ )
+}
\ No newline at end of file
diff --git a/src/config/effects.ts b/src/config/effects.ts
index dca2a737..1f14a522 100644
--- a/src/config/effects.ts
+++ b/src/config/effects.ts
@@ -56,47 +56,34 @@ export const ENGINE_CONTROLS: EffectConfig[] = [
export const EFFECTS: EffectConfig[] = [
{
- id: 'reverb',
- name: 'Reverb',
+ id: 'wavefolder',
+ name: 'Wavefolder',
+ bypassable: true,
parameters: [
{
- id: 'reverbWetDry',
- label: 'Reverb',
+ id: 'clipMode',
+ label: 'Mode',
min: 0,
- max: 100,
+ max: 2,
default: 0,
step: 1,
- unit: '%'
- }
- ]
- },
- {
- id: 'delay',
- name: 'Ping Pong Delay',
- parameters: [
- {
- id: 'delayTime',
- label: 'Time',
- min: 0,
- max: 1000,
- default: 250,
- step: 10,
- unit: 'ms'
+ unit: ''
},
{
- id: 'delayFeedback',
- label: 'Feedback',
- min: 0,
- max: 100,
- default: 50,
- step: 1,
- unit: '%'
+ id: 'wavefolderDrive',
+ label: 'Drive',
+ min: 1,
+ max: 10,
+ default: 1,
+ step: 0.1,
+ unit: 'x'
}
]
},
{
id: 'bitcrush',
name: 'Bitcrush',
+ bypassable: true,
parameters: [
{
id: 'bitcrushDepth',
@@ -119,28 +106,57 @@ export const EFFECTS: EffectConfig[] = [
]
},
{
- id: 'clipmode',
- name: 'Clip Mode',
+ id: 'delay',
+ name: 'Delay',
+ bypassable: true,
parameters: [
{
- id: 'clipMode',
- label: 'Mode',
+ id: 'delayTime',
+ label: 'Time',
min: 0,
- max: 2,
+ max: 1000,
+ default: 250,
+ step: 10,
+ unit: 'ms'
+ },
+ {
+ id: 'delayFeedback',
+ label: 'Feedback',
+ min: 0,
+ max: 100,
+ default: 50,
+ step: 1,
+ unit: '%'
+ }
+ ]
+ },
+ {
+ id: 'reverb',
+ name: 'Reverb',
+ bypassable: true,
+ parameters: [
+ {
+ id: 'reverbWetDry',
+ label: 'Amount',
+ min: 0,
+ max: 100,
default: 0,
step: 1,
- unit: ''
+ unit: '%'
}
]
}
]
-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
})
+ if (effect.bypassable) {
+ defaults[`${effect.id}Bypass`] = true
+ }
})
return defaults
}
@@ -155,7 +171,7 @@ export function getDefaultEngineValues(): Record {
return defaults
}
-export const SAMPLE_RATES = [4000, 8000, 16000, 22050]
+export const SAMPLE_RATES = [8000, 11025, 22050, 44100]
export function getSampleRateFromIndex(index: number): number {
return SAMPLE_RATES[index] || 8000
diff --git a/src/domain/audio/effects/BitcrushEffect.ts b/src/domain/audio/effects/BitcrushEffect.ts
new file mode 100644
index 00000000..9434bd53
--- /dev/null
+++ b/src/domain/audio/effects/BitcrushEffect.ts
@@ -0,0 +1,92 @@
+import type { Effect } from './Effect.interface'
+
+export class BitcrushEffect implements Effect {
+ readonly id = 'bitcrush'
+
+ private inputNode: GainNode
+ private outputNode: GainNode
+ private processorNode: ScriptProcessorNode
+ private wetNode: GainNode
+ private dryNode: GainNode
+ private bitDepth: number = 16
+ private crushAmount: number = 0
+
+ constructor(audioContext: AudioContext) {
+ this.inputNode = audioContext.createGain()
+ this.outputNode = audioContext.createGain()
+ this.processorNode = audioContext.createScriptProcessor(4096, 1, 1)
+ this.wetNode = audioContext.createGain()
+ this.dryNode = audioContext.createGain()
+
+ this.wetNode.gain.value = 1
+ this.dryNode.gain.value = 0
+
+ this.processorNode.onaudioprocess = (e) => {
+ const input = e.inputBuffer.getChannelData(0)
+ const output = e.outputBuffer.getChannelData(0)
+
+ if (this.crushAmount === 0 && this.bitDepth === 16) {
+ output.set(input)
+ return
+ }
+
+ const step = Math.pow(0.5, this.bitDepth)
+ const phaseIncrement = 1 - (this.crushAmount / 100)
+ let phase = 0
+
+ for (let i = 0; i < input.length; i++) {
+ phase += phaseIncrement
+
+ if (phase >= 1.0) {
+ phase -= 1.0
+ const crushed = Math.floor(input[i] / step + 0.5) * step
+ output[i] = Math.max(-1, Math.min(1, crushed))
+ } else {
+ output[i] = i > 0 ? output[i - 1] : 0
+ }
+ }
+ }
+
+ this.inputNode.connect(this.dryNode)
+ this.inputNode.connect(this.processorNode)
+ this.processorNode.connect(this.wetNode)
+ this.dryNode.connect(this.outputNode)
+ this.wetNode.connect(this.outputNode)
+ }
+
+ getInputNode(): AudioNode {
+ return this.inputNode
+ }
+
+ getOutputNode(): AudioNode {
+ return this.outputNode
+ }
+
+ setBypass(bypass: boolean): void {
+ if (bypass) {
+ this.wetNode.gain.value = 0
+ this.dryNode.gain.value = 1
+ } else {
+ this.wetNode.gain.value = 1
+ this.dryNode.gain.value = 0
+ }
+ }
+
+ updateParams(values: Record): void {
+ if (values.bitcrushDepth !== undefined) {
+ this.bitDepth = values.bitcrushDepth
+ }
+
+ if (values.bitcrushRate !== undefined) {
+ this.crushAmount = values.bitcrushRate
+ }
+ }
+
+ dispose(): void {
+ this.processorNode.disconnect()
+ this.wetNode.disconnect()
+ this.dryNode.disconnect()
+ this.inputNode.disconnect()
+ this.outputNode.disconnect()
+ }
+}
\ No newline at end of file
diff --git a/src/domain/audio/effects/DelayEffect.ts b/src/domain/audio/effects/DelayEffect.ts
index 7bc3e910..b3d146ce 100644
--- a/src/domain/audio/effects/DelayEffect.ts
+++ b/src/domain/audio/effects/DelayEffect.ts
@@ -9,6 +9,9 @@ export class DelayEffect implements Effect {
private feedbackNode: GainNode
private wetNode: GainNode
private dryNode: GainNode
+ private bypassed: boolean = false
+ private currentWetValue: number = 0
+ private currentDryValue: number = 1
constructor(audioContext: AudioContext) {
this.inputNode = audioContext.createGain()
@@ -39,12 +42,28 @@ export class DelayEffect implements Effect {
return this.outputNode
}
+ setBypass(bypass: boolean): void {
+ this.bypassed = 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): void {
if (values.delayTime !== undefined) {
this.delayNode.delayTime.value = values.delayTime / 1000
const delayAmount = Math.min(values.delayTime / 1000, 0.5)
- this.wetNode.gain.value = delayAmount
- this.dryNode.gain.value = 1 - delayAmount
+ this.currentWetValue = delayAmount
+ this.currentDryValue = 1 - delayAmount
+
+ if (!this.bypassed) {
+ this.wetNode.gain.value = this.currentWetValue
+ this.dryNode.gain.value = this.currentDryValue
+ }
}
if (values.delayFeedback !== undefined) {
diff --git a/src/domain/audio/effects/Effect.interface.ts b/src/domain/audio/effects/Effect.interface.ts
index d2f2dd3d..af2dbf12 100644
--- a/src/domain/audio/effects/Effect.interface.ts
+++ b/src/domain/audio/effects/Effect.interface.ts
@@ -3,6 +3,7 @@ export interface Effect {
getInputNode(): AudioNode
getOutputNode(): AudioNode
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 1ed63c84..4e0a59ed 100644
--- a/src/domain/audio/effects/EffectsChain.ts
+++ b/src/domain/audio/effects/EffectsChain.ts
@@ -1,7 +1,8 @@
import type { Effect } from './Effect.interface'
import { DelayEffect } from './DelayEffect'
import { ReverbEffect } from './ReverbEffect'
-import { PassThroughEffect } from './PassThroughEffect'
+import { BitcrushEffect } from './BitcrushEffect'
+import { WavefolderEffect } from './WavefolderEffect'
export class EffectsChain {
private inputNode: GainNode
@@ -15,10 +16,10 @@ export class EffectsChain {
this.masterGainNode = audioContext.createGain()
this.effects = [
+ new WavefolderEffect(audioContext),
+ new BitcrushEffect(audioContext),
new DelayEffect(audioContext),
- new ReverbEffect(audioContext),
- new PassThroughEffect(audioContext, 'bitcrush'),
- new PassThroughEffect(audioContext, 'clipmode')
+ new ReverbEffect(audioContext)
]
this.setupChain()
@@ -36,13 +37,26 @@ export class EffectsChain {
this.masterGainNode.connect(this.outputNode)
}
- updateEffects(values: Record): void {
+ updateEffects(values: Record): void {
for (const effect of this.effects) {
- effect.updateParams(values)
+ const effectId = effect.id
+ const bypassKey = `${effectId}Bypass`
+
+ if (values[bypassKey] !== undefined) {
+ effect.setBypass(Boolean(values[bypassKey]))
+ }
+
+ const numericValues: Record = {}
+ for (const [key, value] of Object.entries(values)) {
+ if (typeof value === 'number') {
+ numericValues[key] = value
+ }
+ }
+ effect.updateParams(numericValues)
}
if (values.masterVolume !== undefined) {
- this.masterGainNode.gain.value = values.masterVolume / 100
+ this.masterGainNode.gain.value = Number(values.masterVolume) / 100
}
}
diff --git a/src/domain/audio/effects/PassThroughEffect.ts b/src/domain/audio/effects/PassThroughEffect.ts
index 32274045..5dcb82be 100644
--- a/src/domain/audio/effects/PassThroughEffect.ts
+++ b/src/domain/audio/effects/PassThroughEffect.ts
@@ -18,6 +18,9 @@ export class PassThroughEffect implements Effect {
return this.node
}
+ setBypass(_bypass: boolean): void {
+ }
+
updateParams(_values: Record): void {
}
diff --git a/src/domain/audio/effects/ReverbEffect.ts b/src/domain/audio/effects/ReverbEffect.ts
index 3015a452..0a358138 100644
--- a/src/domain/audio/effects/ReverbEffect.ts
+++ b/src/domain/audio/effects/ReverbEffect.ts
@@ -9,6 +9,9 @@ export class ReverbEffect implements Effect {
private convolverNode: ConvolverNode
private wetNode: GainNode
private dryNode: GainNode
+ private bypassed: boolean = false
+ private currentWetValue: number = 0
+ private currentDryValue: number = 1
constructor(audioContext: AudioContext) {
this.audioContext = audioContext
@@ -52,11 +55,27 @@ export class ReverbEffect implements Effect {
return this.outputNode
}
+ setBypass(bypass: boolean): void {
+ this.bypassed = 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): void {
if (values.reverbWetDry !== undefined) {
const wet = values.reverbWetDry / 100
- this.wetNode.gain.value = wet
- this.dryNode.gain.value = 1 - wet
+ this.currentWetValue = wet
+ this.currentDryValue = 1 - wet
+
+ if (!this.bypassed) {
+ this.wetNode.gain.value = this.currentWetValue
+ this.dryNode.gain.value = this.currentDryValue
+ }
}
}
diff --git a/src/domain/audio/effects/WavefolderEffect.ts b/src/domain/audio/effects/WavefolderEffect.ts
new file mode 100644
index 00000000..7097e364
--- /dev/null
+++ b/src/domain/audio/effects/WavefolderEffect.ts
@@ -0,0 +1,116 @@
+import type { Effect } from './Effect.interface'
+
+type ClipMode = 'wrap' | 'clamp' | 'fold'
+
+export class WavefolderEffect implements Effect {
+ readonly id = 'wavefolder'
+
+ private inputNode: GainNode
+ private outputNode: GainNode
+ private processorNode: ScriptProcessorNode
+ private wetNode: GainNode
+ private dryNode: GainNode
+ private mode: ClipMode = 'wrap'
+ private drive: number = 1
+
+ constructor(audioContext: AudioContext) {
+ this.inputNode = audioContext.createGain()
+ this.outputNode = audioContext.createGain()
+ this.processorNode = audioContext.createScriptProcessor(4096, 1, 1)
+ this.wetNode = audioContext.createGain()
+ this.dryNode = audioContext.createGain()
+
+ this.wetNode.gain.value = 1
+ this.dryNode.gain.value = 0
+
+ this.processorNode.onaudioprocess = (e) => {
+ const input = e.inputBuffer.getChannelData(0)
+ const output = e.outputBuffer.getChannelData(0)
+
+ for (let i = 0; i < input.length; i++) {
+ const driven = input[i] * this.drive
+ output[i] = this.processSample(driven)
+ }
+ }
+
+ this.inputNode.connect(this.dryNode)
+ this.inputNode.connect(this.processorNode)
+ this.processorNode.connect(this.wetNode)
+ this.dryNode.connect(this.outputNode)
+ this.wetNode.connect(this.outputNode)
+ }
+
+ private processSample(sample: number): number {
+ switch (this.mode) {
+ case 'wrap':
+ return this.wrap(sample)
+ case 'clamp':
+ return this.clamp(sample)
+ case 'fold':
+ return this.fold(sample)
+ default:
+ return sample
+ }
+ }
+
+ private wrap(sample: number): number {
+ const range = 2.0
+ let wrapped = sample
+ while (wrapped > 1.0) wrapped -= range
+ while (wrapped < -1.0) wrapped += range
+ return wrapped
+ }
+
+ private clamp(sample: number): number {
+ return Math.max(-1.0, Math.min(1.0, sample))
+ }
+
+ private fold(sample: number): number {
+ 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
+ }
+
+ getInputNode(): AudioNode {
+ return this.inputNode
+ }
+
+ getOutputNode(): AudioNode {
+ return this.outputNode
+ }
+
+ setBypass(bypass: boolean): void {
+ if (bypass) {
+ this.wetNode.gain.value = 0
+ this.dryNode.gain.value = 1
+ } else {
+ this.wetNode.gain.value = 1
+ this.dryNode.gain.value = 0
+ }
+ }
+
+ updateParams(values: Record): void {
+ if (values.clipMode !== undefined) {
+ const modeIndex = values.clipMode
+ this.mode = ['wrap', 'clamp', 'fold'][modeIndex] as ClipMode || 'wrap'
+ }
+ if (values.wavefolderDrive !== undefined) {
+ this.drive = values.wavefolderDrive
+ }
+ }
+
+ dispose(): void {
+ this.processorNode.disconnect()
+ this.wetNode.disconnect()
+ this.dryNode.disconnect()
+ this.inputNode.disconnect()
+ this.outputNode.disconnect()
+ }
+}
\ No newline at end of file
diff --git a/src/lib/bytebeat/EffectsChain.ts b/src/lib/bytebeat/EffectsChain.ts
index a1936173..efd5593a 100644
--- a/src/lib/bytebeat/EffectsChain.ts
+++ b/src/lib/bytebeat/EffectsChain.ts
@@ -78,18 +78,24 @@ export class EffectsChain {
}
updateEffects(values: EffectValues): void {
- const reverbWet = values.reverbWetDry / 100
- this.reverbWetNode.gain.value = reverbWet
- this.reverbDryNode.gain.value = 1 - reverbWet
+ if (typeof values.reverbWetDry === 'number') {
+ const reverbWet = values.reverbWetDry / 100
+ this.reverbWetNode.gain.value = reverbWet
+ this.reverbDryNode.gain.value = 1 - reverbWet
+ }
- this.delayNode.delayTime.value = values.delayTime / 1000
- this.delayFeedbackNode.gain.value = values.delayFeedback / 100
+ if (typeof values.delayTime === 'number') {
+ this.delayNode.delayTime.value = values.delayTime / 1000
+ const delayAmount = Math.min(values.delayTime / 1000, 0.5)
+ this.delayWetNode.gain.value = delayAmount
+ this.delayDryNode.gain.value = 1 - delayAmount
+ }
- const delayAmount = Math.min(values.delayTime / 1000, 0.5)
- this.delayWetNode.gain.value = delayAmount
- this.delayDryNode.gain.value = 1 - delayAmount
+ if (typeof values.delayFeedback === 'number') {
+ this.delayFeedbackNode.gain.value = values.delayFeedback / 100
+ }
- if (values.masterVolume !== undefined) {
+ if (typeof values.masterVolume === 'number') {
this.masterGainNode.gain.value = values.masterVolume / 100
}
}
diff --git a/src/types/effects.ts b/src/types/effects.ts
index 61ff12e4..3101ba43 100644
--- a/src/types/effects.ts
+++ b/src/types/effects.ts
@@ -12,6 +12,7 @@ export interface EffectConfig {
id: string
name: string
parameters: EffectParameter[]
+ bypassable?: boolean
}
-export type EffectValues = Record
\ No newline at end of file
+export type EffectValues = Record
\ No newline at end of file