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