slightly better

This commit is contained in:
2025-10-06 02:16:23 +02:00
parent ba37b94908
commit ac772054c9
35 changed files with 1874 additions and 390 deletions

View File

@ -1,5 +1,6 @@
import { EffectsChain } from './effects/EffectsChain'
import { BytebeatSourceEffect } from './effects/BytebeatSourceEffect'
import { ModulationEngine } from '../modulation/ModulationEngine'
import type { EffectValues } from '../../types/effects'
export interface AudioPlayerOptions {
@ -11,11 +12,13 @@ export class AudioPlayer {
private audioContext: AudioContext | null = null
private bytebeatSource: BytebeatSourceEffect | null = null
private effectsChain: EffectsChain | null = null
private modulationEngine: ModulationEngine | null = null
private effectValues: EffectValues = {}
private startTime: number = 0
private sampleRate: number
private duration: number
private workletRegistered: boolean = false
private currentPitch: number = 1.0
constructor(options: AudioPlayerOptions) {
this.sampleRate = options.sampleRate
@ -51,22 +54,20 @@ export class AudioPlayer {
this.effectsChain.updateEffects(this.effectValues)
if (wasPlaying) {
console.warn('Audio context recreated due to sample rate change. Playback stopped.')
throw new Error('Cannot change sample rate during playback')
}
}
private async registerWorklet(context: AudioContext): Promise<void> {
if (this.workletRegistered) return
try {
await Promise.all([
context.audioWorklet.addModule('/worklets/fold-crush-processor.js'),
context.audioWorklet.addModule('/worklets/bytebeat-processor.js')
])
this.workletRegistered = true
} catch (error) {
console.error('Failed to register AudioWorklet:', error)
}
await Promise.all([
context.audioWorklet.addModule('/worklets/svf-processor.js'),
context.audioWorklet.addModule('/worklets/fold-crush-processor.js'),
context.audioWorklet.addModule('/worklets/bytebeat-processor.js'),
context.audioWorklet.addModule('/worklets/output-limiter.js')
])
this.workletRegistered = true
}
setEffects(values: EffectValues): void {
@ -74,6 +75,15 @@ export class AudioPlayer {
if (this.effectsChain) {
this.effectsChain.updateEffects(values)
}
if (this.modulationEngine) {
const numericValues: Record<string, number> = {}
Object.entries(values).forEach(([key, value]) => {
if (typeof value === 'number') {
numericValues[key] = value
}
})
this.modulationEngine.setBaseValues(numericValues)
}
}
private async ensureAudioContext(): Promise<void> {
@ -87,6 +97,47 @@ export class AudioPlayer {
await this.effectsChain.initialize(this.audioContext)
this.effectsChain.updateEffects(this.effectValues)
}
if (!this.modulationEngine) {
this.modulationEngine = new ModulationEngine(this.audioContext, 4)
this.registerModulatableParams()
}
}
private registerModulatableParams(): void {
if (!this.modulationEngine || !this.effectsChain) return
const effects = this.effectsChain.getEffects()
for (const effect of effects) {
if (effect.getModulatableParams) {
const params = effect.getModulatableParams()
params.forEach((audioParam, paramId) => {
const baseValue = this.effectValues[paramId] as number ?? audioParam.value
this.modulationEngine!.registerParameter(
paramId,
{ audioParam },
baseValue
)
})
}
}
this.modulationEngine.registerParameter(
'pitch',
{ callback: (value: number) => this.applyPitch(value) },
this.currentPitch
)
}
setLFOConfig(lfoIndex: number, config: { frequency: number; phase: number; waveform: string; mappings: Array<{ targetParam: string; depth: number }> }): void {
if (!this.modulationEngine) return
this.modulationEngine.updateLFO(lfoIndex, config.frequency, config.phase, config.waveform as any)
this.modulationEngine.clearMappings(lfoIndex)
for (const mapping of config.mappings) {
this.modulationEngine.addMapping(lfoIndex, mapping.targetParam, mapping.depth)
}
}
async playRealtime(formula: string, a: number, b: number, c: number, d: number): Promise<void> {
@ -100,11 +151,16 @@ export class AudioPlayer {
this.bytebeatSource.setLoopLength(this.sampleRate, this.duration)
this.bytebeatSource.setFormula(formula)
this.bytebeatSource.setVariables(a, b, c, d)
this.bytebeatSource.setPlaybackRate(this.currentPitch)
this.bytebeatSource.reset()
this.bytebeatSource.getOutputNode().connect(this.effectsChain!.getInputNode())
this.effectsChain!.getOutputNode().connect(this.audioContext!.destination)
if (this.modulationEngine) {
this.modulationEngine.start()
}
this.startTime = this.audioContext!.currentTime
}
@ -114,6 +170,20 @@ export class AudioPlayer {
}
}
private applyPitch(pitch: number): void {
if (this.bytebeatSource) {
this.bytebeatSource.setPlaybackRate(pitch)
}
}
updatePitch(pitch: number): void {
this.currentPitch = pitch
this.applyPitch(pitch)
if (this.modulationEngine) {
this.modulationEngine.updateBaseValue('pitch', pitch)
}
}
getPlaybackPosition(): number {
if (!this.audioContext || this.startTime === 0) {
return 0
@ -126,6 +196,9 @@ export class AudioPlayer {
if (this.bytebeatSource) {
this.bytebeatSource.getOutputNode().disconnect()
}
if (this.modulationEngine) {
this.modulationEngine.stop()
}
this.startTime = 0
}
@ -135,6 +208,10 @@ export class AudioPlayer {
this.bytebeatSource.dispose()
this.bytebeatSource = null
}
if (this.modulationEngine) {
this.modulationEngine.dispose()
this.modulationEngine = null
}
if (this.effectsChain) {
this.effectsChain.dispose()
this.effectsChain = null

View File

@ -13,12 +13,8 @@ export class BytebeatSourceEffect implements Effect {
}
async initialize(audioContext: AudioContext): Promise<void> {
try {
this.processorNode = new AudioWorkletNode(audioContext, 'bytebeat-processor')
this.processorNode.connect(this.outputNode)
} catch (error) {
console.error('Failed to initialize BytebeatSourceEffect worklet:', error)
}
this.processorNode = new AudioWorkletNode(audioContext, 'bytebeat-processor')
this.processorNode.connect(this.outputNode)
}
getInputNode(): AudioNode {
@ -53,6 +49,11 @@ export class BytebeatSourceEffect implements Effect {
this.processorNode.port.postMessage({ type: 'loopLength', value: loopLength })
}
setPlaybackRate(rate: number): void {
if (!this.processorNode) return
this.processorNode.port.postMessage({ type: 'playbackRate', value: rate })
}
reset(): void {
if (!this.processorNode) return
this.processorNode.port.postMessage({ type: 'reset' })

View File

@ -4,6 +4,7 @@ export interface Effect {
getOutputNode(): AudioNode
updateParams(values: Record<string, number | string>): void
setBypass(bypass: boolean): void
getModulatableParams?(): Map<string, AudioParam>
dispose(): void
}

View File

@ -3,33 +3,43 @@ import { FilterEffect } from './FilterEffect'
import { FoldCrushEffect } from './FoldCrushEffect'
import { DelayEffect } from './DelayEffect'
import { ReverbEffect } from './ReverbEffect'
import { OutputLimiter } from './OutputLimiter'
export class EffectsChain {
private inputNode: GainNode
private outputNode: GainNode
private masterGainNode: GainNode
private effects: Effect[]
private filterEffect: FilterEffect
private foldCrushEffect: FoldCrushEffect
private outputLimiter: OutputLimiter
constructor(audioContext: AudioContext) {
this.inputNode = audioContext.createGain()
this.outputNode = audioContext.createGain()
this.masterGainNode = audioContext.createGain()
this.filterEffect = new FilterEffect(audioContext)
this.foldCrushEffect = new FoldCrushEffect(audioContext)
this.outputLimiter = new OutputLimiter(audioContext)
this.effects = [
new FilterEffect(audioContext),
this.filterEffect,
this.foldCrushEffect,
new DelayEffect(audioContext),
new ReverbEffect(audioContext)
new ReverbEffect(audioContext),
this.outputLimiter
]
this.setupChain()
}
async initialize(audioContext: AudioContext): Promise<void> {
await this.foldCrushEffect.initialize(audioContext)
await Promise.all([
this.filterEffect.initialize(audioContext),
this.foldCrushEffect.initialize(audioContext),
this.outputLimiter.initialize(audioContext)
])
}
private setupChain(): void {
@ -44,6 +54,10 @@ export class EffectsChain {
this.masterGainNode.connect(this.outputNode)
}
getEffects(): Effect[] {
return this.effects
}
updateEffects(values: Record<string, number | boolean | string>): void {
for (const effect of this.effects) {
const effectId = effect.id

View File

@ -3,20 +3,13 @@ import type { Effect } from './Effect.interface'
export class FilterEffect implements Effect {
readonly id = 'filter'
private audioContext: AudioContext
private inputNode: GainNode
private outputNode: GainNode
private processorNode: AudioWorkletNode | null = null
private wetNode: GainNode
private dryNode: GainNode
private hpFilter: BiquadFilterNode
private lpFilter: BiquadFilterNode
private bpFilter: BiquadFilterNode
private hpEnabled: boolean = false
private lpEnabled: boolean = false
private bpEnabled: boolean = false
constructor(audioContext: AudioContext) {
this.audioContext = audioContext
this.inputNode = audioContext.createGain()
this.outputNode = audioContext.createGain()
this.wetNode = audioContext.createGain()
@ -25,27 +18,14 @@ export class FilterEffect implements Effect {
this.wetNode.gain.value = 0
this.dryNode.gain.value = 1
this.hpFilter = audioContext.createBiquadFilter()
this.hpFilter.type = 'highpass'
this.hpFilter.frequency.value = 1000
this.hpFilter.Q.value = 1
this.lpFilter = audioContext.createBiquadFilter()
this.lpFilter.type = 'lowpass'
this.lpFilter.frequency.value = 5000
this.lpFilter.Q.value = 1
this.bpFilter = audioContext.createBiquadFilter()
this.bpFilter.type = 'bandpass'
this.bpFilter.frequency.value = 1000
this.bpFilter.Q.value = 1
this.inputNode.connect(this.dryNode)
this.inputNode.connect(this.hpFilter)
this.hpFilter.connect(this.lpFilter)
this.lpFilter.connect(this.bpFilter)
this.bpFilter.connect(this.wetNode)
this.dryNode.connect(this.outputNode)
}
async initialize(audioContext: AudioContext): Promise<void> {
this.processorNode = new AudioWorkletNode(audioContext, 'svf-processor')
this.inputNode.connect(this.processorNode)
this.processorNode.connect(this.wetNode)
this.wetNode.connect(this.outputNode)
}
@ -57,117 +37,46 @@ export class FilterEffect implements Effect {
return this.outputNode
}
setBypass(_bypass: boolean): void {
// No global bypass for filters - each filter has individual enable switch
}
private updateBypassState(): void {
const anyEnabled = this.hpEnabled || this.lpEnabled || this.bpEnabled
if (anyEnabled) {
this.wetNode.gain.value = 1
this.dryNode.gain.value = 0
} else {
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
}
}
getModulatableParams(): Map<string, AudioParam> {
if (!this.processorNode) return new Map()
const params = new Map<string, AudioParam>()
params.set('filterFreq', this.processorNode.parameters.get('frequency')!)
params.set('filterRes', this.processorNode.parameters.get('resonance')!)
return params
}
updateParams(values: Record<string, number | string>): void {
if (values.hpEnable !== undefined) {
this.hpEnabled = values.hpEnable === 1
this.updateBypassState()
}
if (!this.processorNode) return
if (values.hpFreq !== undefined && typeof values.hpFreq === 'number') {
this.hpFilter.frequency.cancelScheduledValues(this.audioContext.currentTime)
this.hpFilter.frequency.setValueAtTime(
this.hpFilter.frequency.value,
this.audioContext.currentTime
)
this.hpFilter.frequency.linearRampToValueAtTime(
values.hpFreq,
this.audioContext.currentTime + 0.02
)
if (values.filterMode !== undefined) {
this.processorNode.port.postMessage({ type: 'mode', value: values.filterMode })
}
if (values.hpRes !== undefined && typeof values.hpRes === 'number') {
this.hpFilter.Q.cancelScheduledValues(this.audioContext.currentTime)
this.hpFilter.Q.setValueAtTime(
this.hpFilter.Q.value,
this.audioContext.currentTime
)
this.hpFilter.Q.linearRampToValueAtTime(
values.hpRes,
this.audioContext.currentTime + 0.02
)
if (values.filterFreq !== undefined && typeof values.filterFreq === 'number') {
this.processorNode.parameters.get('frequency')!.value = values.filterFreq
}
if (values.lpEnable !== undefined) {
this.lpEnabled = values.lpEnable === 1
this.updateBypassState()
}
if (values.lpFreq !== undefined && typeof values.lpFreq === 'number') {
this.lpFilter.frequency.cancelScheduledValues(this.audioContext.currentTime)
this.lpFilter.frequency.setValueAtTime(
this.lpFilter.frequency.value,
this.audioContext.currentTime
)
this.lpFilter.frequency.linearRampToValueAtTime(
values.lpFreq,
this.audioContext.currentTime + 0.02
)
}
if (values.lpRes !== undefined && typeof values.lpRes === 'number') {
this.lpFilter.Q.cancelScheduledValues(this.audioContext.currentTime)
this.lpFilter.Q.setValueAtTime(
this.lpFilter.Q.value,
this.audioContext.currentTime
)
this.lpFilter.Q.linearRampToValueAtTime(
values.lpRes,
this.audioContext.currentTime + 0.02
)
}
if (values.bpEnable !== undefined) {
this.bpEnabled = values.bpEnable === 1
this.updateBypassState()
}
if (values.bpFreq !== undefined && typeof values.bpFreq === 'number') {
this.bpFilter.frequency.cancelScheduledValues(this.audioContext.currentTime)
this.bpFilter.frequency.setValueAtTime(
this.bpFilter.frequency.value,
this.audioContext.currentTime
)
this.bpFilter.frequency.linearRampToValueAtTime(
values.bpFreq,
this.audioContext.currentTime + 0.02
)
}
if (values.bpRes !== undefined && typeof values.bpRes === 'number') {
this.bpFilter.Q.cancelScheduledValues(this.audioContext.currentTime)
this.bpFilter.Q.setValueAtTime(
this.bpFilter.Q.value,
this.audioContext.currentTime
)
this.bpFilter.Q.linearRampToValueAtTime(
values.bpRes,
this.audioContext.currentTime + 0.02
)
if (values.filterRes !== undefined && typeof values.filterRes === 'number') {
this.processorNode.parameters.get('resonance')!.value = values.filterRes
}
}
dispose(): void {
this.inputNode.disconnect()
this.outputNode.disconnect()
if (this.processorNode) {
this.processorNode.disconnect()
}
this.wetNode.disconnect()
this.dryNode.disconnect()
this.hpFilter.disconnect()
this.lpFilter.disconnect()
this.bpFilter.disconnect()
this.inputNode.disconnect()
this.outputNode.disconnect()
}
}

View File

@ -23,14 +23,10 @@ export class FoldCrushEffect implements Effect {
}
async initialize(audioContext: AudioContext): Promise<void> {
try {
this.processorNode = new AudioWorkletNode(audioContext, 'fold-crush-processor')
this.inputNode.connect(this.processorNode)
this.processorNode.connect(this.wetNode)
this.wetNode.connect(this.outputNode)
} catch (error) {
console.error('Failed to initialize FoldCrushEffect worklet:', error)
}
this.processorNode = new AudioWorkletNode(audioContext, 'fold-crush-processor')
this.inputNode.connect(this.processorNode)
this.processorNode.connect(this.wetNode)
this.wetNode.connect(this.outputNode)
}
getInputNode(): AudioNode {

View File

@ -0,0 +1,48 @@
import type { Effect } from './Effect.interface'
export class OutputLimiter implements Effect {
readonly id = 'limiter'
private inputNode: GainNode
private outputNode: GainNode
private processorNode: AudioWorkletNode | null = null
constructor(audioContext: AudioContext) {
this.inputNode = audioContext.createGain()
this.outputNode = audioContext.createGain()
this.inputNode.connect(this.outputNode)
}
async initialize(audioContext: AudioContext): Promise<void> {
this.processorNode = new AudioWorkletNode(audioContext, 'output-limiter')
this.inputNode.disconnect()
this.inputNode.connect(this.processorNode)
this.processorNode.connect(this.outputNode)
}
getInputNode(): AudioNode {
return this.inputNode
}
getOutputNode(): AudioNode {
return this.outputNode
}
setBypass(_bypass: boolean): void {
// Output limiter is always on
}
updateParams(_values: Record<string, number | string>): void {
// Uses default parameters from worklet
}
dispose(): void {
if (this.processorNode) {
this.processorNode.disconnect()
}
this.inputNode.disconnect()
this.outputNode.disconnect()
}
}

View File

@ -0,0 +1,66 @@
export type LFOWaveform = 'sine' | 'triangle' | 'square' | 'sawtooth' | 'random'
export class LFO {
private startTime: number
private frequency: number
private phase: number
private waveform: LFOWaveform
private audioContext: AudioContext
private lastRandomValue: number = 0
private lastRandomTime: number = 0
constructor(audioContext: AudioContext, frequency: number = 1, phase: number = 0, waveform: LFOWaveform = 'sine') {
this.audioContext = audioContext
this.frequency = frequency
this.phase = phase
this.waveform = waveform
this.startTime = audioContext.currentTime
}
setFrequency(frequency: number): void {
this.frequency = frequency
}
setPhase(phase: number): void {
this.phase = phase
}
setWaveform(waveform: LFOWaveform): void {
this.waveform = waveform
}
getValue(time?: number): number {
const currentTime = time ?? this.audioContext.currentTime
const elapsed = currentTime - this.startTime
const phaseOffset = (this.phase / 360) * (1 / this.frequency)
const phase = ((elapsed + phaseOffset) * this.frequency) % 1
switch (this.waveform) {
case 'sine':
return Math.sin(phase * 2 * Math.PI)
case 'triangle':
return phase < 0.5
? -1 + 4 * phase
: 3 - 4 * phase
case 'square':
return phase < 0.5 ? 1 : -1
case 'sawtooth':
return 2 * phase - 1
case 'random': {
const interval = 1 / this.frequency
if (currentTime - this.lastRandomTime >= interval) {
this.lastRandomValue = Math.random() * 2 - 1
this.lastRandomTime = currentTime
}
return this.lastRandomValue
}
default:
return 0
}
}
}

View File

@ -0,0 +1,159 @@
import { LFO, type LFOWaveform } from './LFO'
import { parameterRegistry } from './ParameterRegistry'
export interface LFOMapping {
lfoIndex: number
targetParam: string
depth: number
}
export interface ParameterTarget {
audioParam?: AudioParam
callback?: (value: number) => void
}
export class ModulationEngine {
private audioContext: AudioContext
private lfos: LFO[]
private mappings: LFOMapping[]
private paramTargets: Map<string, ParameterTarget>
private baseValues: Map<string, number>
private animationFrameId: number | null = null
private isRunning: boolean = false
constructor(audioContext: AudioContext, lfoCount: number = 4) {
this.audioContext = audioContext
this.lfos = []
this.mappings = []
this.paramTargets = new Map()
this.baseValues = new Map()
for (let i = 0; i < lfoCount; i++) {
this.lfos.push(new LFO(audioContext))
}
}
registerParameter(paramId: string, target: ParameterTarget, baseValue: number): void {
this.paramTargets.set(paramId, target)
this.baseValues.set(paramId, baseValue)
}
updateBaseValue(paramId: string, baseValue: number): void {
this.baseValues.set(paramId, baseValue)
}
setBaseValues(values: Record<string, number>): void {
Object.entries(values).forEach(([paramId, value]) => {
this.baseValues.set(paramId, value)
})
}
addMapping(lfoIndex: number, targetParam: string, depth: number): void {
const existingIndex = this.mappings.findIndex(
m => m.lfoIndex === lfoIndex && m.targetParam === targetParam
)
if (existingIndex >= 0) {
this.mappings[existingIndex].depth = depth
} else {
this.mappings.push({ lfoIndex, targetParam, depth })
}
}
removeMapping(lfoIndex: number, targetParam: string): void {
this.mappings = this.mappings.filter(
m => !(m.lfoIndex === lfoIndex && m.targetParam === targetParam)
)
}
clearMappings(lfoIndex?: number): void {
if (lfoIndex !== undefined) {
this.mappings = this.mappings.filter(m => m.lfoIndex !== lfoIndex)
} else {
this.mappings = []
}
}
updateLFO(lfoIndex: number, frequency: number, phase: number, waveform: LFOWaveform): void {
const lfo = this.lfos[lfoIndex]
if (!lfo) return
lfo.setFrequency(frequency)
lfo.setPhase(phase)
lfo.setWaveform(waveform)
}
private updateModulation = (): void => {
if (!this.isRunning) return
const currentTime = this.audioContext.currentTime
const modulatedValues = new Map<string, number>()
for (const [paramId, baseValue] of this.baseValues) {
modulatedValues.set(paramId, baseValue)
}
for (const mapping of this.mappings) {
const lfo = this.lfos[mapping.lfoIndex]
if (!lfo) continue
const baseValue = this.baseValues.get(mapping.targetParam)
if (baseValue === undefined) continue
const meta = parameterRegistry.getMetadata(mapping.targetParam)
if (!meta) continue
const lfoValue = lfo.getValue(currentTime)
const normalized = parameterRegistry.normalizeValue(mapping.targetParam, baseValue)
const depthNormalized = (mapping.depth / 100) * lfoValue
const modulatedNormalized = normalized + depthNormalized
const modulatedValue = parameterRegistry.denormalizeValue(mapping.targetParam, modulatedNormalized)
const clampedValue = parameterRegistry.clampValue(mapping.targetParam, modulatedValue)
modulatedValues.set(mapping.targetParam, clampedValue)
}
for (const [paramId, value] of modulatedValues) {
const target = this.paramTargets.get(paramId)
if (!target) continue
if (target.audioParam) {
target.audioParam.setTargetAtTime(value, currentTime, 0.01)
} else if (target.callback) {
target.callback(value)
}
}
this.animationFrameId = requestAnimationFrame(this.updateModulation)
}
start(): void {
if (this.isRunning) return
this.isRunning = true
this.updateModulation()
}
stop(): void {
this.isRunning = false
if (this.animationFrameId !== null) {
cancelAnimationFrame(this.animationFrameId)
this.animationFrameId = null
}
}
getMappingsForLFO(lfoIndex: number): LFOMapping[] {
return this.mappings.filter(m => m.lfoIndex === lfoIndex)
}
getMappingsForParam(paramId: string): LFOMapping[] {
return this.mappings.filter(m => m.targetParam === paramId)
}
dispose(): void {
this.stop()
this.mappings = []
this.paramTargets.clear()
this.baseValues.clear()
}
}

View File

@ -0,0 +1,133 @@
import { ENGINE_CONTROLS, EFFECTS } from '../../config/effects'
import type { EffectParameter } from '../../types/effects'
export type ParameterScaling = 'linear' | 'exponential'
export interface ParameterMetadata {
id: string
label: string
min: number
max: number
step: number
unit?: string
scaling: ParameterScaling
isAudioParam: boolean
category: 'engine' | 'effect'
effectId?: string
}
export class ParameterRegistry {
private metadata: Map<string, ParameterMetadata> = new Map()
constructor() {
this.buildRegistry()
}
private buildRegistry(): void {
ENGINE_CONTROLS.forEach(control => {
control.parameters.forEach(param => {
if (this.isNumericParameter(param)) {
this.metadata.set(param.id, {
id: param.id,
label: param.label,
min: param.min,
max: param.max,
step: param.step,
unit: param.unit,
scaling: 'linear',
isAudioParam: false,
category: 'engine'
})
}
})
})
EFFECTS.forEach(effect => {
effect.parameters.forEach(param => {
if (this.isNumericParameter(param)) {
const isFreqParam = param.id.toLowerCase().includes('freq')
const isAudioParam = this.checkIfAudioParam(effect.id, param.id)
this.metadata.set(param.id, {
id: param.id,
label: param.label,
min: param.min,
max: param.max,
step: param.step,
unit: param.unit,
scaling: isFreqParam ? 'exponential' : 'linear',
isAudioParam,
category: 'effect',
effectId: effect.id
})
}
})
})
}
private isNumericParameter(param: EffectParameter): boolean {
return typeof param.default === 'number' && !param.options
}
private checkIfAudioParam(effectId: string, paramId: string): boolean {
if (effectId !== 'filter') return false
const audioParamIds = ['hpFreq', 'hpRes', 'lpFreq', 'lpRes', 'bpFreq', 'bpRes']
return audioParamIds.includes(paramId)
}
getMetadata(paramId: string): ParameterMetadata | undefined {
return this.metadata.get(paramId)
}
isAudioParam(paramId: string): boolean {
return this.metadata.get(paramId)?.isAudioParam ?? false
}
getAllModulatableParams(): string[] {
return Array.from(this.metadata.keys())
}
getModulatableParamsByCategory(category: 'engine' | 'effect'): string[] {
return Array.from(this.metadata.entries())
.filter(([_, meta]) => meta.category === category)
.map(([id, _]) => id)
}
clampValue(paramId: string, value: number): number {
const meta = this.metadata.get(paramId)
if (!meta) return value
return Math.max(meta.min, Math.min(meta.max, value))
}
normalizeValue(paramId: string, value: number): number {
const meta = this.metadata.get(paramId)
if (!meta) return 0
if (meta.scaling === 'exponential') {
const logMin = Math.log(meta.min)
const logMax = Math.log(meta.max)
const logValue = Math.log(Math.max(meta.min, value))
return (logValue - logMin) / (logMax - logMin)
} else {
return (value - meta.min) / (meta.max - meta.min)
}
}
denormalizeValue(paramId: string, normalized: number): number {
const meta = this.metadata.get(paramId)
if (!meta) return 0
const clamped = Math.max(0, Math.min(1, normalized))
if (meta.scaling === 'exponential') {
const logMin = Math.log(meta.min)
const logMax = Math.log(meta.max)
return Math.exp(logMin + clamped * (logMax - logMin))
} else {
return meta.min + clamped * (meta.max - meta.min)
}
}
}
export const parameterRegistry = new ParameterRegistry()