diff --git a/index.html b/index.html index d454d4f9..e476995e 100644 --- a/index.html +++ b/index.html @@ -12,6 +12,7 @@ const cacophony = new Cacophony(); window.cacophony = cacophony; let sound; + let isSynth = false; document.getElementById('playSound').addEventListener('click', async () => { const soundUrl = document.getElementById('soundUrl').value; @@ -26,15 +27,34 @@ type: soundUrl, panType: panType, }); - + isSynth = true; break; default: sound = await cacophony.createSound(soundUrl, SoundType.Buffer, panType); break; } if (loopCheckbox) sound.loop('infinite'); - sound.play(); -window.sound = sound; + if (!isSynth) sound.play(); + if (isSynth) { + sound.applyFrequencyEnvelope({ + attack: 0.25, + decay: 0.2, + sustain: 0.3, + release: 0.15, + sustainLevel: 440, + minValue: 220, + maxValue: 880 + }) + sound.applyVolumeEnvelope({ + attack: 0.25, + decay: 0.35, + sustain: 1, + release: 0.15, + sustainLevel: 1, + }); + sound.play(); + } + window.sound = sound; }); document.getElementById('stopSound').addEventListener('click', () => { diff --git a/src/adsr.ts b/src/adsr.ts new file mode 100644 index 00000000..27bef3ff --- /dev/null +++ b/src/adsr.ts @@ -0,0 +1,135 @@ +import { IAudioParam } from "standardized-audio-context"; + +export enum EnvelopeType { + Linear = 'linear', + Exponential = 'exponential', + Logarithmic = 'logarithmic' +} + +export interface ADSREnvelope { + attack: number; + decay: number; + sustain: number; + release: number; + sustainLevel: number; + duration: number; + minValue: number; + maxValue: number; + attackType: EnvelopeType; + decayType: EnvelopeType; + releaseType: EnvelopeType; +} + +export class ADSR { + envelope: ADSREnvelope; + + constructor(envelope: Partial) { + this.envelope = { + attack: 0, + decay: 0, + sustain: 0, + release: 0, + sustainLevel: 1, + duration: 0, + minValue: 0, + maxValue: 1, + attackType: EnvelopeType.Linear, + decayType: EnvelopeType.Linear, + releaseType: EnvelopeType.Linear, + ...envelope + }; + + this.validateEnvelope(); + } + + private validateEnvelope(): void { + if (this.envelope.duration > 0 && this.envelope.duration < this.getTotalDuration()) { + console.warn('Envelope duration is shorter than the sum of ADSR phases. This may result in unexpected behavior.'); + } + } + + updateEnvelope(newEnvelope: Partial): void { + this.envelope = { ...this.envelope, ...newEnvelope }; + this.validateEnvelope(); + } + + getTotalDuration(): number { + return this.envelope.attack + this.envelope.decay + this.envelope.sustain + this.envelope.release; + } + + applyToParam( + audioParam: IAudioParam, + startTime: number, + duration: number + ): void { + if (!duration || !this.envelope.duration) { + // calculate duration based on all envelope properties including sustain. + duration = this.envelope.attack + this.envelope.decay + this.envelope.sustain + this.envelope.release; + } + const endTime = startTime + duration; + this.applyADSR(audioParam, this.envelope, startTime, endTime); + } + + private applyADSR( + audioParam: IAudioParam, + envelope: ADSREnvelope, + startTime: number, + endTime: number + ): void { + const { attack, decay, sustain, release, sustainLevel, minValue, maxValue, attackType, decayType, releaseType } = envelope; + + const attackEnd = startTime + attack; + const decayEnd = attackEnd + decay; + const sustainEnd = decayEnd + sustain; + const releaseEnd = Math.min(endTime, sustainEnd + release); + + // Apply attack + audioParam.cancelScheduledValues(startTime); + audioParam.setValueAtTime(minValue, startTime); + this.applyEnvelopeSegment(audioParam, attackType, startTime, attackEnd, minValue, maxValue); + + // Apply decay + this.applyEnvelopeSegment(audioParam, decayType, attackEnd, decayEnd, maxValue, sustainLevel); + + // Sustain + audioParam.setValueAtTime(sustainLevel, decayEnd); + + // Apply release + if (releaseEnd > sustainEnd) { + this.applyEnvelopeSegment(audioParam, releaseType, sustainEnd, releaseEnd, sustainLevel, minValue); + } + } + + private applyEnvelopeSegment( + audioParam: IAudioParam, + envelopeType: EnvelopeType, + startTime: number, + endTime: number, + startValue: number, + endValue: number + ): void { + const duration = endTime - startTime; + + switch (envelopeType) { + case EnvelopeType.Linear: + audioParam.linearRampToValueAtTime(endValue, endTime); + break; + case EnvelopeType.Exponential: + // Avoid zero values for exponential ramps + const safeStartValue = Math.max(startValue, 0.0001); + const safeEndValue = Math.max(endValue, 0.0001); + audioParam.exponentialRampToValueAtTime(safeEndValue, endTime); + break; + case EnvelopeType.Logarithmic: + // Implement logarithmic ramp using setValueCurveAtTime + const curveLength = Math.ceil(duration * 1000); // 1 point per millisecond + const curve = new Float32Array(curveLength); + for (let i = 0; i < curveLength; i++) { + const t = i / (curveLength - 1); + curve[i] = startValue + (endValue - startValue) * (Math.log1p(t * 99) / Math.log(100)); + } + audioParam.setValueCurveAtTime(curve, startTime, duration); + break; + } + } +} diff --git a/src/cacophony.test.ts b/src/cacophony.test.ts index ce7dd2b8..f946b17d 100644 --- a/src/cacophony.test.ts +++ b/src/cacophony.test.ts @@ -5,6 +5,7 @@ import { Sound } from './sound'; import { Synth } from './synth'; import { SynthPlayback } from './synthPlayback'; import { Playback } from './playback'; +import { EnvelopeType } from './adsr'; let cacophony: Cacophony; let audioContextMock: AudioContext; @@ -270,6 +271,89 @@ describe('Playback class', () => { expect(playback.source).toBeUndefined(); }); }); + +describe('ADSR Envelope', () => { + let synth: Synth; + let audioContextMock: AudioContext; + + beforeEach(() => { + audioContextMock = new AudioContext(); + synth = new Synth(audioContextMock, audioContextMock.createGain()); + }); + + it('can apply volume envelope', () => { + const volumeEnvelope = { + attack: 0.1, + decay: 0.2, + sustain: 0.5, + release: 0.3, + sustainLevel: 0.7, + duration: 1, + minValue: 0, + maxValue: 1, + attackType: EnvelopeType.Linear, + decayType: EnvelopeType.Linear, + releaseType: EnvelopeType.Linear + }; + synth.applyVolumeEnvelope(volumeEnvelope); + expect(synth.synthEnvelopes.volumeEnvelope).toEqual(volumeEnvelope); + }); + + it('can apply frequency envelope', () => { + const frequencyEnvelope = { + attack: 0.1, + decay: 0.2, + sustain: 0.5, + release: 0.3, + sustainLevel: 0.7, + duration: 1, + minValue: 220, + maxValue: 880, + attackType: EnvelopeType.Exponential, + decayType: EnvelopeType.Exponential, + releaseType: EnvelopeType.Exponential + }; + synth.applyFrequencyEnvelope(frequencyEnvelope); + expect(synth.synthEnvelopes.frequencyEnvelope).toEqual(frequencyEnvelope); + }); + + it('can apply detune envelope', () => { + const detuneEnvelope = { + attack: 0.1, + decay: 0.2, + sustain: 0.5, + release: 0.3, + sustainLevel: 0.7, + duration: 1, + minValue: -100, + maxValue: 100, + attackType: EnvelopeType.Linear, + decayType: EnvelopeType.Linear, + releaseType: EnvelopeType.Linear + }; + synth.applyDetuneEnvelope(detuneEnvelope); + expect(synth.synthEnvelopes.detuneEnvelope).toEqual(detuneEnvelope); + }); + + it('applies envelopes to playbacks', () => { + const volumeEnvelope = { + attack: 0.1, + decay: 0.2, + sustain: 0.5, + release: 0.3, + sustainLevel: 0.7, + duration: 1, + minValue: 0, + maxValue: 1, + attackType: EnvelopeType.Linear, + decayType: EnvelopeType.Linear, + releaseType: EnvelopeType.Linear + }; + synth.applyVolumeEnvelope(volumeEnvelope); + const playbacks = synth.preplay(); + expect(playbacks[0].synthEnvelopes.volumeEnvelope).toEqual(volumeEnvelope); + }); +}); it('createOscillator creates an oscillator with default parameters when none are provided', () => { const synth = cacophony.createOscillator({}); expect(synth.type).toBe('sine'); diff --git a/src/context.ts b/src/context.ts index 446303c2..fe684795 100644 --- a/src/context.ts +++ b/src/context.ts @@ -1,4 +1,4 @@ -import { AudioContext, IAudioBuffer, IAudioBufferSourceNode, IAudioNode, IBiquadFilterNode, IGainNode, IMediaElementAudioSourceNode, IMediaStreamAudioSourceNode, IOscillatorNode, IPannerNode, IStereoPannerNode } from 'standardized-audio-context'; +import { AudioContext, IAudioBuffer, IAudioBufferSourceNode, IAudioContext, IAudioNode, IBiquadFilterNode, IConstantSourceNode, IGainNode, IMediaElementAudioSourceNode, IMediaStreamAudioSourceNode, IOscillatorNode, IPannerNode, IStereoPannerNode } from 'standardized-audio-context'; export type AudioNode = IAudioNode; export type BiquadFilterNode = IBiquadFilterNode; export type MediaElementSourceNode = IMediaElementAudioSourceNode; @@ -10,4 +10,6 @@ export type MediaStreamAudioSourceNode = IMediaStreamAudioSourceNode; export type PannerNode = IPannerNode; export type StereoPannerNode = IStereoPannerNode; +export type ConstantSourceNode = IConstantSourceNode; + export { AudioContext }; diff --git a/src/index.ts b/src/index.ts index 53e792b3..59a3070b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -4,4 +4,6 @@ export { MicrophonePlayback } from './microphone'; export * from './playback'; export { Sound } from './sound'; export { Synth } from './synth'; -export { SynthGroup } from './synthGroup'; \ No newline at end of file +export { SynthGroup } from './synthGroup'; +export * from "./lfo"; +export * from "./adsr"; diff --git a/src/lfo.ts b/src/lfo.ts new file mode 100644 index 00000000..23b43cb5 --- /dev/null +++ b/src/lfo.ts @@ -0,0 +1,178 @@ +import { IAudioParam } from "standardized-audio-context"; +import { AudioContext, GainNode, OscillatorNode, ConstantSourceNode } from "./context"; + +export class LFO { + private oscillator: OscillatorNode; + private gainNode: GainNode; + private depthNode: GainNode; + private offsetNode: ConstantSourceNode; + private customWaveform: PeriodicWave | null = null; + private isPlaying: boolean = false; + private pausedAt: number | null = null; + private initialState: { + frequency: number; + depth: number; + waveform: OscillatorType | "custom"; + phase: number; + bipolar: boolean; + }; + + constructor( + private context: AudioContext, + public frequency: number = 1, + public depth: number = 1, + public waveform: OscillatorType | "custom" = "sine", + public phase: number = 0, + public bipolar: boolean = false, + public shape?: number[] + ) { + this.oscillator = this.context.createOscillator(); + this.gainNode = this.context.createGain(); + this.depthNode = this.context.createGain(); + this.offsetNode = this.context.createConstantSource(); + + if (waveform === "custom" && shape) { + this.setCustomWaveform(shape); + } else { + this.oscillator.type = waveform as OscillatorType; + } + this.oscillator.frequency.value = this.frequency; + this.depthNode.gain.value = this.depth; + this.offsetNode.offset.value = this.bipolar ? 0 : 0.5; + + this.oscillator.connect(this.gainNode); + this.gainNode.connect(this.depthNode); + this.depthNode.connect(this.offsetNode); + + this.setPhase(this.phase); + + this.initialState = { + frequency, + depth, + waveform, + phase, + bipolar, + }; + } + + connect(param: IAudioParam): void { + this.offsetNode.connect(param as AudioParam); + } + + disconnect(): void { + this.offsetNode.disconnect(); + } + + start(time?: number): void { + if (!this.isPlaying) { + this.oscillator.start(time); + this.offsetNode.start(time); + this.isPlaying = true; + this.pausedAt = null; + } + } + + stop(time?: number): void { + if (this.isPlaying) { + this.oscillator.stop(time); + this.offsetNode.stop(time); + this.isPlaying = false; + this.pausedAt = null; + } + } + + pause(): void { + if (this.isPlaying) { + this.stop(this.context.currentTime); + this.pausedAt = this.context.currentTime; + } + } + + resume(): void { + if (this.pausedAt !== null) { + this.start(this.context.currentTime); + this.syncToTime(this.pausedAt); + this.pausedAt = null; + } + } + + reset(): void { + this.stop(); + this.setFrequency(this.initialState.frequency); + this.setDepth(this.initialState.depth); + this.setWaveform(this.initialState.waveform, this.shape); + this.setPhase(this.initialState.phase); + this.setBipolar(this.initialState.bipolar); + } + + setFrequency(value: number, time?: number): void { + this.oscillator.frequency.setValueAtTime( + value, + time || this.context.currentTime + ); + this.frequency = value; + } + + setDepth(value: number, time?: number): void { + this.depthNode.gain.setValueAtTime(value, time || this.context.currentTime); + this.depth = value; + } + + setWaveform(waveform: OscillatorType | "custom", shape?: number[]): void { + if (waveform === "custom" && shape) { + this.setCustomWaveform(shape); + } else { + this.oscillator.type = waveform as OscillatorType; + this.customWaveform = null; + } + this.waveform = waveform; + this.shape = shape; + } + + setCustomWaveform(shape: number[]): void { + const real = new Float32Array(shape.length); + const imag = new Float32Array(shape.length); + shape.forEach((value, index) => { + real[index] = 0; + imag[index] = value; + }); + this.customWaveform = this.context.createPeriodicWave(real, imag); + this.oscillator.setPeriodicWave(this.customWaveform); + } + + setPhase(phase: number): void { + const normalizedPhase = ((phase % 360) + 360) % 360; + const delayTime = normalizedPhase / 360 / this.frequency; + this.oscillator.disconnect(); + const delayNode = this.context.createDelay(delayTime); + this.oscillator.connect(delayNode); + delayNode.connect(this.gainNode); + this.phase = normalizedPhase; + } + + setBipolar(bipolar: boolean): void { + this.bipolar = bipolar; + this.offsetNode.offset.setValueAtTime( + bipolar ? 0 : 0.5, + this.context.currentTime + ); + } + + syncToTime(time: number): void { + const currentTime = this.context.currentTime; + const phaseDifference = ((time - currentTime) * this.frequency) % 1; + this.setPhase(phaseDifference * 360); + } + + modulateDepth(depth: number, duration: number): void { + const now = this.context.currentTime; + this.depthNode.gain.setValueAtTime(this.depth, now); + this.depthNode.gain.linearRampToValueAtTime(depth, now + duration); + this.depth = depth; + } + + static synchronize(...lfos: LFO[]): void { + const now = lfos[0].context.currentTime; + lfos.forEach((lfo) => lfo.syncToTime(now)); + } +} diff --git a/src/oscillatorMixin.ts b/src/oscillatorMixin.ts index 9f5cde10..981cab35 100644 --- a/src/oscillatorMixin.ts +++ b/src/oscillatorMixin.ts @@ -1,5 +1,7 @@ import { BasePlayback } from "./basePlayback"; import type { OscillatorNode } from "./context"; +import { ADSR, ADSREnvelope } from "./adsr"; +import { IAudioContext } from "standardized-audio-context"; export type OscillatorCloneOverrides = { oscillatorOptions?: Partial; @@ -10,7 +12,9 @@ type Constructor = abstract new (...args: any[]) => T; export function OscillatorMixin(Base: TBase) { abstract class OscillatorMixin extends BasePlayback { _oscillatorOptions: Partial = {}; + oscillatorEnvelopes: OscillatorEnvelopes = {}; declare public source?: OscillatorNode; + declare context: IAudioContext; get oscillatorOptions(): Partial { return this._oscillatorOptions; @@ -32,6 +36,12 @@ export function OscillatorMixin(Base: TBase) { if (this.oscillatorOptions.detune) this.source.detune.value = this.oscillatorOptions.detune; if (this.oscillatorOptions.frequency) this.source.frequency.value = this.oscillatorOptions.frequency; if (this.oscillatorOptions.type) this.source.type = this.oscillatorOptions.type; + if (this.oscillatorEnvelopes.frequencyEnvelope) { + this.oscillatorEnvelopes.frequencyEnvelope.applyToParam(this.source.frequency, this.context.currentTime, this.oscillatorEnvelopes.frequencyEnvelope.envelope.duration); + } + if (this.oscillatorEnvelopes.detuneEnvelope) { + this.oscillatorEnvelopes.detuneEnvelope.applyToParam(this.source.detune, this.context.currentTime, this.oscillatorEnvelopes.detuneEnvelope.envelope.duration); + } this.source.start(); this._playing = true; return [this]; @@ -66,6 +76,16 @@ export function OscillatorMixin(Base: TBase) { this.oscillatorOptions.detune = detune; } + applyDetuneEnvelope(adsr: ADSREnvelope): void { + const instance = new ADSR(adsr); + this.oscillatorEnvelopes.detuneEnvelope = instance; + } + + applyFrequencyEnvelope(adsr: ADSREnvelope): void { + const instance = new ADSR(adsr); + this.oscillatorEnvelopes.frequencyEnvelope = instance; + } + get type(): OscillatorType { return this.source!.type; } @@ -74,7 +94,12 @@ export function OscillatorMixin(Base: TBase) { this.source!.type = type; this.oscillatorOptions.type = type; } - + }; return OscillatorMixin; } + +interface OscillatorEnvelopes { + frequencyEnvelope?: ADSR; + detuneEnvelope?: ADSR; +} \ No newline at end of file diff --git a/src/playback.ts b/src/playback.ts index df93eefa..6374f621 100644 --- a/src/playback.ts +++ b/src/playback.ts @@ -35,7 +35,7 @@ type PlaybackCloneOverrides = { }; export class Playback extends BasePlayback implements BaseSound { - private context: AudioContext; + context: AudioContext; public declare source?: SourceNode; loopCount: LoopCount = 0; currentLoop: number = 0; @@ -403,9 +403,7 @@ export class Playback extends BasePlayback implements BaseSound { * @throws {Error} Throws an error if the sound has been cleaned up. */ - clone( - overrides: Partial = {} - ): Playback { + clone(overrides: Partial = {}): Playback { if (!this.source || !this.gainNode || !this.context) { throw new Error("Cannot clone a sound that has been cleaned up"); } diff --git a/src/synth.ts b/src/synth.ts index c020c979..57d8dd71 100644 --- a/src/synth.ts +++ b/src/synth.ts @@ -1,8 +1,10 @@ +import { ADSREnvelope } from "./adsr"; import { SoundType, type BaseSound, type PanType } from "./cacophony"; import { PlaybackContainer } from "./container"; import type { AudioContext, GainNode } from './context'; import type { FilterCloneOverrides } from "./filters"; import { FilterManager } from "./filters"; +import { LFO } from "./lfo"; import type { OscillatorCloneOverrides } from "./oscillatorMixin"; import type { PanCloneOverrides } from "./pannerMixin"; import { SynthPlayback } from "./synthPlayback"; @@ -12,7 +14,11 @@ type SynthCloneOverrides = FilterCloneOverrides & OscillatorCloneOverrides & Pan export class Synth extends PlaybackContainer(FilterManager) implements BaseSound { _oscillatorOptions: Partial; + synthEnvelopes: SynthEnvelopes = {}; playbacks: SynthPlayback[] = []; + frequencyLFO?: LFO; + detuneLFO?: LFO; + volumeLFO?: LFO; constructor( public context: AudioContext, @@ -72,6 +78,13 @@ export class Synth extends PlaybackContainer(FilterManager) implements BaseSound const playback = new SynthPlayback(oscillator, gainNode, this.context, this.panType); playback.volume = this.volume; this._filters.forEach(filter => playback.addFilter(filter)); + + // Envelope handling + playback.synthEnvelopes = { ...this.synthEnvelopes }; + if (this.synthEnvelopes.detuneEnvelope) playback.applyDetuneEnvelope(this.synthEnvelopes.detuneEnvelope); + if (this.synthEnvelopes.frequencyEnvelope) playback.applyFrequencyEnvelope(this.synthEnvelopes.frequencyEnvelope); + if (this.synthEnvelopes.volumeEnvelope) playback.applyVolumeEnvelope(this.synthEnvelopes.volumeEnvelope); + if (this.panType === 'HRTF') { playback.threeDOptions = this.threeDOptions; playback.position = this.position; @@ -126,4 +139,96 @@ export class Synth extends PlaybackContainer(FilterManager) implements BaseSound this.playbacks.forEach((p) => p.type = type); } + + applyFrequencyEnvelope(envelope: ADSREnvelope): void { + this.synthEnvelopes.frequencyEnvelope = envelope; + this.playbacks.forEach(p => p.applyFrequencyEnvelope(envelope)); + } + + applyDetuneEnvelope(envelope: ADSREnvelope): void { + this.synthEnvelopes.detuneEnvelope = envelope; + this.playbacks.forEach(p => p.applyDetuneEnvelope(envelope)); + } + + applyVolumeEnvelope(envelope: ADSREnvelope): void { + this.synthEnvelopes.volumeEnvelope = envelope; + this.playbacks.forEach(p => p.applyVolumeEnvelope(envelope)); + } + + setFrequencyLFO(frequency: number, depth: number, waveform: OscillatorType | 'custom' = 'sine', phase: number = 0, bipolar: boolean = false, shape?: number[]): void { + this.frequencyLFO = new LFO(this.context, frequency, depth, waveform, phase, bipolar, shape); + this.playbacks.forEach(p => { + if (p.source instanceof OscillatorNode) { + this.frequencyLFO!.connect(p.source.frequency); + } + }); + this.frequencyLFO.start(); + } + + setDetuneLFO(frequency: number, depth: number, waveform: OscillatorType | 'custom' = 'sine', phase: number = 0, bipolar: boolean = true, shape?: number[]): void { + this.detuneLFO = new LFO(this.context, frequency, depth, waveform, phase, bipolar, shape); + this.playbacks.forEach(p => { + if (p.source instanceof OscillatorNode) { + this.detuneLFO!.connect(p.source.detune); + } + }); + this.detuneLFO.start(); + } + + setVolumeLFO(frequency: number, depth: number, waveform: OscillatorType | 'custom' = 'sine', phase: number = 0, bipolar: boolean = false, shape?: number[]): void { + this.volumeLFO = new LFO(this.context, frequency, depth, waveform, phase, bipolar, shape); + this.playbacks.forEach(p => { + this.volumeLFO!.connect(p.gainNode.gain); + }); + this.volumeLFO.start(); + } + + stopLFOs(): void { + if (this.frequencyLFO) this.frequencyLFO.stop(); + if (this.detuneLFO) this.detuneLFO.stop(); + if (this.volumeLFO) this.volumeLFO.stop(); + } + + pauseLFOs(): void { + if (this.frequencyLFO) this.frequencyLFO.pause(); + if (this.detuneLFO) this.detuneLFO.pause(); + if (this.volumeLFO) this.volumeLFO.pause(); + } + + resumeLFOs(): void { + if (this.frequencyLFO) this.frequencyLFO.resume(); + if (this.detuneLFO) this.detuneLFO.resume(); + if (this.volumeLFO) this.volumeLFO.resume(); + } + + resetLFOs(): void { + if (this.frequencyLFO) this.frequencyLFO.reset(); + if (this.detuneLFO) this.detuneLFO.reset(); + if (this.volumeLFO) this.volumeLFO.reset(); + } + + syncLFOs(time: number): void { + if (this.frequencyLFO) this.frequencyLFO.syncToTime(time); + if (this.detuneLFO) this.detuneLFO.syncToTime(time); + if (this.volumeLFO) this.volumeLFO.syncToTime(time); + } + + modulateLFODepths(depth: number, duration: number): void { + if (this.frequencyLFO) this.frequencyLFO.modulateDepth(depth, duration); + if (this.detuneLFO) this.detuneLFO.modulateDepth(depth, duration); + if (this.volumeLFO) this.volumeLFO.modulateDepth(depth, duration); + } + + static synchronizeAllLFOs(...synths: Synth[]): void { + const allLFOs = synths.flatMap(synth => + [synth.frequencyLFO, synth.detuneLFO, synth.volumeLFO].filter(Boolean) as LFO[] + ); + LFO.synchronize(...allLFOs); + } +} + +export interface SynthEnvelopes { + volumeEnvelope?: ADSREnvelope; + frequencyEnvelope?: ADSREnvelope; + detuneEnvelope?: ADSREnvelope; } diff --git a/src/synthPlayback.ts b/src/synthPlayback.ts index b87318a5..309e8b3b 100644 --- a/src/synthPlayback.ts +++ b/src/synthPlayback.ts @@ -1,12 +1,19 @@ import type { BaseSound, PanType } from "./cacophony"; import type { AudioContext, GainNode, OscillatorNode } from "./context"; import { FilterManager } from "./filters"; +import { LFO } from "./lfo"; import { OscillatorMixin } from "./oscillatorMixin"; import { PannerMixin } from "./pannerMixin"; import { VolumeMixin } from "./volumeMixin"; +import { SynthEnvelopes } from "./synth"; export class SynthPlayback extends OscillatorMixin(PannerMixin(VolumeMixin(FilterManager))) implements BaseSound { - constructor(public source: OscillatorNode, gainNode: GainNode, private context: AudioContext, panType: PanType = 'HRTF') { + synthEnvelopes: SynthEnvelopes = {}; + frequencyLFO?: LFO; + detuneLFO?: LFO; + volumeLFO?: LFO; + + constructor(public source: OscillatorNode, public gainNode: GainNode, public context: AudioContext, panType: PanType = 'HRTF') { super() this.setPanType(panType, context) this.source.connect(this.panner!); @@ -15,6 +22,30 @@ export class SynthPlayback extends OscillatorMixin(PannerMixin(VolumeMixin(Filte this.refreshFilters() } + setFrequencyLFO(lfo: LFO): void { + if (this.frequencyLFO) { + this.frequencyLFO.disconnect(); + } + this.frequencyLFO = lfo; + this.frequencyLFO.connect(this.source.frequency); + } + + setDetuneLFO(lfo: LFO): void { + if (this.detuneLFO) { + this.detuneLFO.disconnect(); + } + this.detuneLFO = lfo; + this.detuneLFO.connect(this.source.detune); + } + + setVolumeLFO(lfo: LFO): void { + if (this.volumeLFO) { + this.volumeLFO.disconnect(); + } + this.volumeLFO = lfo; + this.volumeLFO.connect(this.gainNode.gain); + } + /** * Refreshes the audio filters by re-applying them to the audio signal chain. * This method is called internally whenever filters are added or removed. diff --git a/src/volumeMixin.ts b/src/volumeMixin.ts index 3d2dffab..577bbc49 100644 --- a/src/volumeMixin.ts +++ b/src/volumeMixin.ts @@ -1,4 +1,5 @@ -import { GainNode } from "./context"; +import { ADSR, ADSREnvelope } from "./adsr"; +import type { AudioContext, GainNode } from "./context"; import { FilterManager } from "./filters"; export type VolumeCloneOverrides = { @@ -10,6 +11,8 @@ type Constructor = abstract new (...args: any[]) => T; export function VolumeMixin(Base: TBase) { abstract class VolumeMixin extends Base { gainNode?: GainNode; + volumeEnvelopes: VolumeEnvelopes = {}; + declare context: AudioContext; setGainNode(gainNode: GainNode) { this.gainNode = gainNode; @@ -41,7 +44,19 @@ export function VolumeMixin(Base: TBase) { this.gainNode.gain.value = v; } + // apply envelope + applyVolumeEnvelope(envelope: ADSREnvelope): void { + if (!this.gainNode) { + throw new Error('Cannot apply volume envelope to a sound that has been cleaned up'); + } + const instance = new ADSR(envelope); + instance.applyToParam(this.gainNode.gain, this.context.currentTime, envelope.duration); + } }; return VolumeMixin; } + +interface VolumeEnvelopes { + volumeEnvelope?: ADSR; +} \ No newline at end of file