slightly better
This commit is contained in:
@ -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
|
||||
|
||||
@ -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' })
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
|
||||
48
src/domain/audio/effects/OutputLimiter.ts
Normal file
48
src/domain/audio/effects/OutputLimiter.ts
Normal 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()
|
||||
}
|
||||
}
|
||||
66
src/domain/modulation/LFO.ts
Normal file
66
src/domain/modulation/LFO.ts
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
159
src/domain/modulation/ModulationEngine.ts
Normal file
159
src/domain/modulation/ModulationEngine.ts
Normal 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()
|
||||
}
|
||||
}
|
||||
133
src/domain/modulation/ParameterRegistry.ts
Normal file
133
src/domain/modulation/ParameterRegistry.ts
Normal 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()
|
||||
Reference in New Issue
Block a user