Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 23 additions & 3 deletions index.html
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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', () => {
Expand Down
135 changes: 135 additions & 0 deletions src/adsr.ts
Original file line number Diff line number Diff line change
@@ -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<ADSREnvelope>) {
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<ADSREnvelope>): 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;
}
}
}
84 changes: 84 additions & 0 deletions src/cacophony.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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');
Expand Down
4 changes: 3 additions & 1 deletion src/context.ts
Original file line number Diff line number Diff line change
@@ -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<AudioContext>;
export type BiquadFilterNode = IBiquadFilterNode<AudioContext>;
export type MediaElementSourceNode = IMediaElementAudioSourceNode<AudioContext>;
Expand All @@ -10,4 +10,6 @@ export type MediaStreamAudioSourceNode = IMediaStreamAudioSourceNode<AudioContex
export type GainNode = IGainNode<AudioContext>;
export type PannerNode = IPannerNode<AudioContext>;
export type StereoPannerNode = IStereoPannerNode<AudioContext>;
export type ConstantSourceNode = IConstantSourceNode<AudioContext>;

export { AudioContext };
4 changes: 3 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,6 @@ export { MicrophonePlayback } from './microphone';
export * from './playback';
export { Sound } from './sound';
export { Synth } from './synth';
export { SynthGroup } from './synthGroup';
export { SynthGroup } from './synthGroup';
export * from "./lfo";
export * from "./adsr";
Loading