reorganization

This commit is contained in:
2025-09-30 16:56:14 +02:00
parent 16dd4c08bf
commit 84cd1f3603
15 changed files with 425 additions and 92 deletions

View File

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

View File

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

View File

@ -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 (
<div className="bg-black border-t-2 border-white px-6 py-4">
<div className="grid grid-cols-4 gap-6">
{EFFECTS.flatMap(effect =>
effect.parameters.map(param => (
<Slider
key={param.id}
label={param.label}
value={values[param.id] ?? param.default}
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}
/>
))
)}
<div className="grid grid-cols-4 gap-4">
{EFFECTS.map(effect => (
<div key={effect.id} className="border-2 border-white p-3">
<div className="flex items-center justify-between mb-3">
<h3 className="font-mono text-[10px] tracking-[0.2em] text-white">
{effect.name.toUpperCase()}
</h3>
{effect.bypassable && (
<Switch
checked={!Boolean(values[`${effect.id}Bypass`])}
onChange={(checked) => onChange(`${effect.id}Bypass`, !checked)}
label={Boolean(values[`${effect.id}Bypass`]) ? 'OFF' : 'ON'}
/>
)}
</div>
<div className="flex flex-col gap-3">
{effect.parameters.map(param => (
<Slider
key={param.id}
label={param.label}
value={values[param.id] as number ?? param.default}
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}
/>
))}
</div>
</div>
))}
</div>
</div>
)

View File

@ -31,7 +31,7 @@ export function EngineControls({ values, onChange }: EngineControlsProps) {
{param.label.toUpperCase()}
</label>
<span className="font-mono text-[9px] text-white">
{formatValue(param.id, values[param.id] ?? param.default)}
{formatValue(param.id, (values[param.id] as number) ?? param.default)}
</span>
</div>
<input
@ -39,7 +39,7 @@ export function EngineControls({ values, onChange }: EngineControlsProps) {
min={param.min}
max={param.max}
step={param.step}
value={values[param.id] ?? param.default}
value={(values[param.id] as number) ?? param.default}
onChange={(e) => onChange(param.id, Number(e.target.value))}
className="w-full h-[2px] bg-white appearance-none cursor-pointer"
/>

29
src/components/Switch.tsx Normal file
View File

@ -0,0 +1,29 @@
interface SwitchProps {
checked: boolean
onChange: (checked: boolean) => void
label?: string
}
export function Switch({ checked, onChange, label }: SwitchProps) {
return (
<label className="flex items-center gap-2 cursor-pointer">
<div
className={`relative w-8 h-4 border-2 transition-colors ${
checked ? 'bg-white border-white' : 'bg-black border-white'
}`}
onClick={() => onChange(!checked)}
>
<div
className={`absolute top-0 w-3 h-3 transition-transform ${
checked ? 'translate-x-4 bg-black' : 'translate-x-0 bg-white'
}`}
/>
</div>
{label && (
<span className="font-mono text-[9px] text-white select-none">
{label}
</span>
)}
</label>
)
}

View File

@ -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<string, number> {
const defaults: Record<string, number> = {}
export function getDefaultEffectValues(): Record<string, number | boolean> {
const defaults: Record<string, number | boolean> = {}
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<string, number> {
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

View File

@ -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<string, number>): 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()
}
}

View File

@ -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<string, number>): 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) {

View File

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

View File

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

View File

@ -18,6 +18,9 @@ export class PassThroughEffect implements Effect {
return this.node
}
setBypass(_bypass: boolean): void {
}
updateParams(_values: Record<string, number>): void {
}

View File

@ -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<string, number>): 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
}
}
}

View File

@ -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<string, number>): 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()
}
}

View File

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

View File

@ -12,6 +12,7 @@ export interface EffectConfig {
id: string
name: string
parameters: EffectParameter[]
bypassable?: boolean
}
export type EffectValues = Record<string, number>
export type EffectValues = Record<string, number | boolean>