reorganization
This commit is contained in:
24
node_modules/.vite/deps/_metadata.json
generated
vendored
24
node_modules/.vite/deps/_metadata.json
generated
vendored
@ -1,61 +1,61 @@
|
|||||||
{
|
{
|
||||||
"hash": "43216e8d",
|
"hash": "480f37ab",
|
||||||
"configHash": "0b9ed782",
|
"configHash": "e652d8ff",
|
||||||
"lockfileHash": "dadb379e",
|
"lockfileHash": "dadb379e",
|
||||||
"browserHash": "4f0bb7c4",
|
"browserHash": "55e2ae32",
|
||||||
"optimized": {
|
"optimized": {
|
||||||
"@nanostores/persistent": {
|
"@nanostores/persistent": {
|
||||||
"src": "../../.pnpm/@nanostores+persistent@1.1.0_nanostores@1.0.1/node_modules/@nanostores/persistent/index.js",
|
"src": "../../.pnpm/@nanostores+persistent@1.1.0_nanostores@1.0.1/node_modules/@nanostores/persistent/index.js",
|
||||||
"file": "@nanostores_persistent.js",
|
"file": "@nanostores_persistent.js",
|
||||||
"fileHash": "7596f610",
|
"fileHash": "41270c63",
|
||||||
"needsInterop": false
|
"needsInterop": false
|
||||||
},
|
},
|
||||||
"@nanostores/react": {
|
"@nanostores/react": {
|
||||||
"src": "../../.pnpm/@nanostores+react@1.0.0_nanostores@1.0.1_react@19.1.1/node_modules/@nanostores/react/index.js",
|
"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",
|
"file": "@nanostores_react.js",
|
||||||
"fileHash": "19e1624e",
|
"fileHash": "125eb5ad",
|
||||||
"needsInterop": false
|
"needsInterop": false
|
||||||
},
|
},
|
||||||
"jszip": {
|
"jszip": {
|
||||||
"src": "../../.pnpm/jszip@3.10.1/node_modules/jszip/dist/jszip.min.js",
|
"src": "../../.pnpm/jszip@3.10.1/node_modules/jszip/dist/jszip.min.js",
|
||||||
"file": "jszip.js",
|
"file": "jszip.js",
|
||||||
"fileHash": "e59e7578",
|
"fileHash": "ea14d80a",
|
||||||
"needsInterop": true
|
"needsInterop": true
|
||||||
},
|
},
|
||||||
"lucide-react": {
|
"lucide-react": {
|
||||||
"src": "../../.pnpm/lucide-react@0.544.0_react@19.1.1/node_modules/lucide-react/dist/esm/lucide-react.js",
|
"src": "../../.pnpm/lucide-react@0.544.0_react@19.1.1/node_modules/lucide-react/dist/esm/lucide-react.js",
|
||||||
"file": "lucide-react.js",
|
"file": "lucide-react.js",
|
||||||
"fileHash": "f256b1cf",
|
"fileHash": "347cd426",
|
||||||
"needsInterop": false
|
"needsInterop": false
|
||||||
},
|
},
|
||||||
"react-dom": {
|
"react-dom": {
|
||||||
"src": "../../.pnpm/react-dom@19.1.1_react@19.1.1/node_modules/react-dom/index.js",
|
"src": "../../.pnpm/react-dom@19.1.1_react@19.1.1/node_modules/react-dom/index.js",
|
||||||
"file": "react-dom.js",
|
"file": "react-dom.js",
|
||||||
"fileHash": "74c66a5f",
|
"fileHash": "67fe1167",
|
||||||
"needsInterop": true
|
"needsInterop": true
|
||||||
},
|
},
|
||||||
"react-dom/client": {
|
"react-dom/client": {
|
||||||
"src": "../../.pnpm/react-dom@19.1.1_react@19.1.1/node_modules/react-dom/client.js",
|
"src": "../../.pnpm/react-dom@19.1.1_react@19.1.1/node_modules/react-dom/client.js",
|
||||||
"file": "react-dom_client.js",
|
"file": "react-dom_client.js",
|
||||||
"fileHash": "41baaeaa",
|
"fileHash": "9c5fd5d6",
|
||||||
"needsInterop": true
|
"needsInterop": true
|
||||||
},
|
},
|
||||||
"react": {
|
"react": {
|
||||||
"src": "../../.pnpm/react@19.1.1/node_modules/react/index.js",
|
"src": "../../.pnpm/react@19.1.1/node_modules/react/index.js",
|
||||||
"file": "react.js",
|
"file": "react.js",
|
||||||
"fileHash": "37acbd53",
|
"fileHash": "35a8de87",
|
||||||
"needsInterop": true
|
"needsInterop": true
|
||||||
},
|
},
|
||||||
"react/jsx-dev-runtime": {
|
"react/jsx-dev-runtime": {
|
||||||
"src": "../../.pnpm/react@19.1.1/node_modules/react/jsx-dev-runtime.js",
|
"src": "../../.pnpm/react@19.1.1/node_modules/react/jsx-dev-runtime.js",
|
||||||
"file": "react_jsx-dev-runtime.js",
|
"file": "react_jsx-dev-runtime.js",
|
||||||
"fileHash": "bf9c5ec3",
|
"fileHash": "675ff029",
|
||||||
"needsInterop": true
|
"needsInterop": true
|
||||||
},
|
},
|
||||||
"react/jsx-runtime": {
|
"react/jsx-runtime": {
|
||||||
"src": "../../.pnpm/react@19.1.1/node_modules/react/jsx-runtime.js",
|
"src": "../../.pnpm/react@19.1.1/node_modules/react/jsx-runtime.js",
|
||||||
"file": "react_jsx-runtime.js",
|
"file": "react_jsx-runtime.js",
|
||||||
"fileHash": "082ac5fc",
|
"fileHash": "4bc2e93d",
|
||||||
"needsInterop": true
|
"needsInterop": true
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@ -114,8 +114,8 @@ function App() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleEffectChange = (parameterId: string, value: number) => {
|
const handleEffectChange = (parameterId: string, value: number | boolean) => {
|
||||||
effectSettings.setKey(parameterId as keyof typeof effectValues, value)
|
effectSettings.setKey(parameterId as any, value as any)
|
||||||
if (playbackManagerRef.current) {
|
if (playbackManagerRef.current) {
|
||||||
playbackManagerRef.current.setEffects(effectValues)
|
playbackManagerRef.current.setEffects(effectValues)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,11 +1,12 @@
|
|||||||
import { Slider } from './Slider'
|
import { Slider } from './Slider'
|
||||||
|
import { Switch } from './Switch'
|
||||||
import { EFFECTS } from '../config/effects'
|
import { EFFECTS } from '../config/effects'
|
||||||
import { getClipModeLabel } from '../utils/formatters'
|
import { getClipModeLabel } from '../utils/formatters'
|
||||||
import type { EffectValues } from '../types/effects'
|
import type { EffectValues } from '../types/effects'
|
||||||
|
|
||||||
interface EffectsBarProps {
|
interface EffectsBarProps {
|
||||||
values: EffectValues
|
values: EffectValues
|
||||||
onChange: (parameterId: string, value: number) => void
|
onChange: (parameterId: string, value: number | boolean) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export function EffectsBar({ values, onChange }: EffectsBarProps) {
|
export function EffectsBar({ values, onChange }: EffectsBarProps) {
|
||||||
@ -18,13 +19,27 @@ export function EffectsBar({ values, onChange }: EffectsBarProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-black border-t-2 border-white px-6 py-4">
|
<div className="bg-black border-t-2 border-white px-6 py-4">
|
||||||
<div className="grid grid-cols-4 gap-6">
|
<div className="grid grid-cols-4 gap-4">
|
||||||
{EFFECTS.flatMap(effect =>
|
{EFFECTS.map(effect => (
|
||||||
effect.parameters.map(param => (
|
<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
|
<Slider
|
||||||
key={param.id}
|
key={param.id}
|
||||||
label={param.label}
|
label={param.label}
|
||||||
value={values[param.id] ?? param.default}
|
value={values[param.id] as number ?? param.default}
|
||||||
min={param.min}
|
min={param.min}
|
||||||
max={param.max}
|
max={param.max}
|
||||||
step={param.step}
|
step={param.step}
|
||||||
@ -33,8 +48,10 @@ export function EffectsBar({ values, onChange }: EffectsBarProps) {
|
|||||||
formatValue={param.id === 'clipMode' ? formatValue : undefined}
|
formatValue={param.id === 'clipMode' ? formatValue : undefined}
|
||||||
valueId={param.id}
|
valueId={param.id}
|
||||||
/>
|
/>
|
||||||
))
|
))}
|
||||||
)}
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@ -31,7 +31,7 @@ export function EngineControls({ values, onChange }: EngineControlsProps) {
|
|||||||
{param.label.toUpperCase()}
|
{param.label.toUpperCase()}
|
||||||
</label>
|
</label>
|
||||||
<span className="font-mono text-[9px] text-white">
|
<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>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<input
|
<input
|
||||||
@ -39,7 +39,7 @@ export function EngineControls({ values, onChange }: EngineControlsProps) {
|
|||||||
min={param.min}
|
min={param.min}
|
||||||
max={param.max}
|
max={param.max}
|
||||||
step={param.step}
|
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))}
|
onChange={(e) => onChange(param.id, Number(e.target.value))}
|
||||||
className="w-full h-[2px] bg-white appearance-none cursor-pointer"
|
className="w-full h-[2px] bg-white appearance-none cursor-pointer"
|
||||||
/>
|
/>
|
||||||
|
|||||||
29
src/components/Switch.tsx
Normal file
29
src/components/Switch.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -56,47 +56,34 @@ export const ENGINE_CONTROLS: EffectConfig[] = [
|
|||||||
|
|
||||||
export const EFFECTS: EffectConfig[] = [
|
export const EFFECTS: EffectConfig[] = [
|
||||||
{
|
{
|
||||||
id: 'reverb',
|
id: 'wavefolder',
|
||||||
name: 'Reverb',
|
name: 'Wavefolder',
|
||||||
|
bypassable: true,
|
||||||
parameters: [
|
parameters: [
|
||||||
{
|
{
|
||||||
id: 'reverbWetDry',
|
id: 'clipMode',
|
||||||
label: 'Reverb',
|
label: 'Mode',
|
||||||
min: 0,
|
min: 0,
|
||||||
max: 100,
|
max: 2,
|
||||||
default: 0,
|
default: 0,
|
||||||
step: 1,
|
step: 1,
|
||||||
unit: '%'
|
unit: ''
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'delay',
|
id: 'wavefolderDrive',
|
||||||
name: 'Ping Pong Delay',
|
label: 'Drive',
|
||||||
parameters: [
|
min: 1,
|
||||||
{
|
max: 10,
|
||||||
id: 'delayTime',
|
default: 1,
|
||||||
label: 'Time',
|
step: 0.1,
|
||||||
min: 0,
|
unit: 'x'
|
||||||
max: 1000,
|
|
||||||
default: 250,
|
|
||||||
step: 10,
|
|
||||||
unit: 'ms'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'delayFeedback',
|
|
||||||
label: 'Feedback',
|
|
||||||
min: 0,
|
|
||||||
max: 100,
|
|
||||||
default: 50,
|
|
||||||
step: 1,
|
|
||||||
unit: '%'
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'bitcrush',
|
id: 'bitcrush',
|
||||||
name: 'Bitcrush',
|
name: 'Bitcrush',
|
||||||
|
bypassable: true,
|
||||||
parameters: [
|
parameters: [
|
||||||
{
|
{
|
||||||
id: 'bitcrushDepth',
|
id: 'bitcrushDepth',
|
||||||
@ -119,28 +106,57 @@ export const EFFECTS: EffectConfig[] = [
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'clipmode',
|
id: 'delay',
|
||||||
name: 'Clip Mode',
|
name: 'Delay',
|
||||||
|
bypassable: true,
|
||||||
parameters: [
|
parameters: [
|
||||||
{
|
{
|
||||||
id: 'clipMode',
|
id: 'delayTime',
|
||||||
label: 'Mode',
|
label: 'Time',
|
||||||
min: 0,
|
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,
|
default: 0,
|
||||||
step: 1,
|
step: 1,
|
||||||
unit: ''
|
unit: '%'
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
||||||
export function getDefaultEffectValues(): Record<string, number> {
|
export function getDefaultEffectValues(): Record<string, number | boolean> {
|
||||||
const defaults: Record<string, number> = {}
|
const defaults: Record<string, number | boolean> = {}
|
||||||
EFFECTS.forEach(effect => {
|
EFFECTS.forEach(effect => {
|
||||||
effect.parameters.forEach(param => {
|
effect.parameters.forEach(param => {
|
||||||
defaults[param.id] = param.default
|
defaults[param.id] = param.default
|
||||||
})
|
})
|
||||||
|
if (effect.bypassable) {
|
||||||
|
defaults[`${effect.id}Bypass`] = true
|
||||||
|
}
|
||||||
})
|
})
|
||||||
return defaults
|
return defaults
|
||||||
}
|
}
|
||||||
@ -155,7 +171,7 @@ export function getDefaultEngineValues(): Record<string, number> {
|
|||||||
return defaults
|
return defaults
|
||||||
}
|
}
|
||||||
|
|
||||||
export const SAMPLE_RATES = [4000, 8000, 16000, 22050]
|
export const SAMPLE_RATES = [8000, 11025, 22050, 44100]
|
||||||
|
|
||||||
export function getSampleRateFromIndex(index: number): number {
|
export function getSampleRateFromIndex(index: number): number {
|
||||||
return SAMPLE_RATES[index] || 8000
|
return SAMPLE_RATES[index] || 8000
|
||||||
|
|||||||
92
src/domain/audio/effects/BitcrushEffect.ts
Normal file
92
src/domain/audio/effects/BitcrushEffect.ts
Normal 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -9,6 +9,9 @@ export class DelayEffect implements Effect {
|
|||||||
private feedbackNode: GainNode
|
private feedbackNode: GainNode
|
||||||
private wetNode: GainNode
|
private wetNode: GainNode
|
||||||
private dryNode: GainNode
|
private dryNode: GainNode
|
||||||
|
private bypassed: boolean = false
|
||||||
|
private currentWetValue: number = 0
|
||||||
|
private currentDryValue: number = 1
|
||||||
|
|
||||||
constructor(audioContext: AudioContext) {
|
constructor(audioContext: AudioContext) {
|
||||||
this.inputNode = audioContext.createGain()
|
this.inputNode = audioContext.createGain()
|
||||||
@ -39,12 +42,28 @@ export class DelayEffect implements Effect {
|
|||||||
return this.outputNode
|
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 {
|
updateParams(values: Record<string, number>): void {
|
||||||
if (values.delayTime !== undefined) {
|
if (values.delayTime !== undefined) {
|
||||||
this.delayNode.delayTime.value = values.delayTime / 1000
|
this.delayNode.delayTime.value = values.delayTime / 1000
|
||||||
const delayAmount = Math.min(values.delayTime / 1000, 0.5)
|
const delayAmount = Math.min(values.delayTime / 1000, 0.5)
|
||||||
this.wetNode.gain.value = delayAmount
|
this.currentWetValue = delayAmount
|
||||||
this.dryNode.gain.value = 1 - delayAmount
|
this.currentDryValue = 1 - delayAmount
|
||||||
|
|
||||||
|
if (!this.bypassed) {
|
||||||
|
this.wetNode.gain.value = this.currentWetValue
|
||||||
|
this.dryNode.gain.value = this.currentDryValue
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (values.delayFeedback !== undefined) {
|
if (values.delayFeedback !== undefined) {
|
||||||
|
|||||||
@ -3,6 +3,7 @@ export interface Effect {
|
|||||||
getInputNode(): AudioNode
|
getInputNode(): AudioNode
|
||||||
getOutputNode(): AudioNode
|
getOutputNode(): AudioNode
|
||||||
updateParams(values: Record<string, number>): void
|
updateParams(values: Record<string, number>): void
|
||||||
|
setBypass(bypass: boolean): void
|
||||||
dispose(): void
|
dispose(): void
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,7 +1,8 @@
|
|||||||
import type { Effect } from './Effect.interface'
|
import type { Effect } from './Effect.interface'
|
||||||
import { DelayEffect } from './DelayEffect'
|
import { DelayEffect } from './DelayEffect'
|
||||||
import { ReverbEffect } from './ReverbEffect'
|
import { ReverbEffect } from './ReverbEffect'
|
||||||
import { PassThroughEffect } from './PassThroughEffect'
|
import { BitcrushEffect } from './BitcrushEffect'
|
||||||
|
import { WavefolderEffect } from './WavefolderEffect'
|
||||||
|
|
||||||
export class EffectsChain {
|
export class EffectsChain {
|
||||||
private inputNode: GainNode
|
private inputNode: GainNode
|
||||||
@ -15,10 +16,10 @@ export class EffectsChain {
|
|||||||
this.masterGainNode = audioContext.createGain()
|
this.masterGainNode = audioContext.createGain()
|
||||||
|
|
||||||
this.effects = [
|
this.effects = [
|
||||||
|
new WavefolderEffect(audioContext),
|
||||||
|
new BitcrushEffect(audioContext),
|
||||||
new DelayEffect(audioContext),
|
new DelayEffect(audioContext),
|
||||||
new ReverbEffect(audioContext),
|
new ReverbEffect(audioContext)
|
||||||
new PassThroughEffect(audioContext, 'bitcrush'),
|
|
||||||
new PassThroughEffect(audioContext, 'clipmode')
|
|
||||||
]
|
]
|
||||||
|
|
||||||
this.setupChain()
|
this.setupChain()
|
||||||
@ -36,13 +37,26 @@ export class EffectsChain {
|
|||||||
this.masterGainNode.connect(this.outputNode)
|
this.masterGainNode.connect(this.outputNode)
|
||||||
}
|
}
|
||||||
|
|
||||||
updateEffects(values: Record<string, number>): void {
|
updateEffects(values: Record<string, number | boolean>): void {
|
||||||
for (const effect of this.effects) {
|
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) {
|
if (values.masterVolume !== undefined) {
|
||||||
this.masterGainNode.gain.value = values.masterVolume / 100
|
this.masterGainNode.gain.value = Number(values.masterVolume) / 100
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -18,6 +18,9 @@ export class PassThroughEffect implements Effect {
|
|||||||
return this.node
|
return this.node
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setBypass(_bypass: boolean): void {
|
||||||
|
}
|
||||||
|
|
||||||
updateParams(_values: Record<string, number>): void {
|
updateParams(_values: Record<string, number>): void {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -9,6 +9,9 @@ export class ReverbEffect implements Effect {
|
|||||||
private convolverNode: ConvolverNode
|
private convolverNode: ConvolverNode
|
||||||
private wetNode: GainNode
|
private wetNode: GainNode
|
||||||
private dryNode: GainNode
|
private dryNode: GainNode
|
||||||
|
private bypassed: boolean = false
|
||||||
|
private currentWetValue: number = 0
|
||||||
|
private currentDryValue: number = 1
|
||||||
|
|
||||||
constructor(audioContext: AudioContext) {
|
constructor(audioContext: AudioContext) {
|
||||||
this.audioContext = audioContext
|
this.audioContext = audioContext
|
||||||
@ -52,11 +55,27 @@ export class ReverbEffect implements Effect {
|
|||||||
return this.outputNode
|
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 {
|
updateParams(values: Record<string, number>): void {
|
||||||
if (values.reverbWetDry !== undefined) {
|
if (values.reverbWetDry !== undefined) {
|
||||||
const wet = values.reverbWetDry / 100
|
const wet = values.reverbWetDry / 100
|
||||||
this.wetNode.gain.value = wet
|
this.currentWetValue = wet
|
||||||
this.dryNode.gain.value = 1 - wet
|
this.currentDryValue = 1 - wet
|
||||||
|
|
||||||
|
if (!this.bypassed) {
|
||||||
|
this.wetNode.gain.value = this.currentWetValue
|
||||||
|
this.dryNode.gain.value = this.currentDryValue
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
116
src/domain/audio/effects/WavefolderEffect.ts
Normal file
116
src/domain/audio/effects/WavefolderEffect.ts
Normal 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -78,18 +78,24 @@ export class EffectsChain {
|
|||||||
}
|
}
|
||||||
|
|
||||||
updateEffects(values: EffectValues): void {
|
updateEffects(values: EffectValues): void {
|
||||||
|
if (typeof values.reverbWetDry === 'number') {
|
||||||
const reverbWet = values.reverbWetDry / 100
|
const reverbWet = values.reverbWetDry / 100
|
||||||
this.reverbWetNode.gain.value = reverbWet
|
this.reverbWetNode.gain.value = reverbWet
|
||||||
this.reverbDryNode.gain.value = 1 - reverbWet
|
this.reverbDryNode.gain.value = 1 - reverbWet
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof values.delayTime === 'number') {
|
||||||
this.delayNode.delayTime.value = values.delayTime / 1000
|
this.delayNode.delayTime.value = values.delayTime / 1000
|
||||||
this.delayFeedbackNode.gain.value = values.delayFeedback / 100
|
|
||||||
|
|
||||||
const delayAmount = Math.min(values.delayTime / 1000, 0.5)
|
const delayAmount = Math.min(values.delayTime / 1000, 0.5)
|
||||||
this.delayWetNode.gain.value = delayAmount
|
this.delayWetNode.gain.value = delayAmount
|
||||||
this.delayDryNode.gain.value = 1 - delayAmount
|
this.delayDryNode.gain.value = 1 - delayAmount
|
||||||
|
}
|
||||||
|
|
||||||
if (values.masterVolume !== undefined) {
|
if (typeof values.delayFeedback === 'number') {
|
||||||
|
this.delayFeedbackNode.gain.value = values.delayFeedback / 100
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof values.masterVolume === 'number') {
|
||||||
this.masterGainNode.gain.value = values.masterVolume / 100
|
this.masterGainNode.gain.value = values.masterVolume / 100
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -12,6 +12,7 @@ export interface EffectConfig {
|
|||||||
id: string
|
id: string
|
||||||
name: string
|
name: string
|
||||||
parameters: EffectParameter[]
|
parameters: EffectParameter[]
|
||||||
|
bypassable?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export type EffectValues = Record<string, number>
|
export type EffectValues = Record<string, number | boolean>
|
||||||
Reference in New Issue
Block a user