Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
11 changes: 11 additions & 0 deletions src/logic/DrumMachine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,17 @@ export class DrumMachine {
this.params[drum] = { pitch, decay }
}

/**
* Synchronizes all internal parameters from an external state.
*/
syncInternalParams(kit: '808' | '909', drive: number, drumParams: Record<string, { pitch: number, decay: number }>) {
this.setKit(kit)
this.setSaturation(drive)
Object.entries(drumParams).forEach(([id, p]) => {
this.setDrumParams(id, p.pitch, p.decay)
})
}

triggerDrum(drum: 'kick' | 'snare' | 'hihat' | 'hihatOpen' | 'clap' | 'cowbell', time: number, velocity: number = 0.8) {
const p = this.params[drum]
const kit808 = this.kit808
Expand Down
40 changes: 40 additions & 0 deletions src/logic/DrumUtils.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,47 @@
import * as Tone from 'tone'

/**
* Shared DSP utilities for drum synthesis based on research specs.
*/

/**
* Generates a white noise buffer.
* @param context - Tone.js or Web Audio context
* @param duration - Duration in seconds
*/
export function generateWhiteNoise(context: Tone.BaseContext, duration: number = 2.0): AudioBuffer {
const sampleRate = context.sampleRate;
const bufferSize = Math.floor(sampleRate * duration);
const buffer = context.createBuffer(1, bufferSize, sampleRate);
const data = buffer.getChannelData(0);
for (let i = 0; i < bufferSize; i++) {
data[i] = Math.random() * 2 - 1;
}
return buffer;
}

/**
* Generates a 15-bit LFSR (pseudo-random) noise buffer.
* Uses characteristic polynomial x^15 + x^14 + 1.
* @param context - Tone.js or Web Audio context
* @param duration - Duration in seconds
*/
export function generateLFSRNoise(context: Tone.BaseContext, duration: number = 2.0): AudioBuffer {
const sampleRate = context.sampleRate;
const bufferSize = Math.floor(sampleRate * duration);
const buffer = context.createBuffer(1, bufferSize, sampleRate);
const data = buffer.getChannelData(0);

let lfsr = 0x7FFF; // 15-bit state
for (let i = 0; i < bufferSize; i++) {
// Galois LFSR for x^15 + x^14 + 1
const bit = ((lfsr >> 14) ^ (lfsr >> 13)) & 1;
lfsr = ((lfsr << 1) | bit) & 0x7FFF;
data[i] = (lfsr / 0x3FFF) - 1.0;
}
return buffer;
}

/**
* Creates a soft-clipping saturation curve (hyperbolic tangent approximation).
* Formula: (3 + k) * x * 20 * deg / (Math.PI + k * Math.abs(x))
Expand Down
10 changes: 4 additions & 6 deletions src/logic/drums/TR808Clap.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,16 @@
import * as Tone from 'tone'
import { applyVariance } from '../DrumUtils'
import { applyVariance, generateWhiteNoise } from '../DrumUtils'

export class TR808Clap {
private noiseBuffer: AudioBuffer;

constructor(private destination: Tone.ToneAudioNode) {
const sampleRate = Tone.getContext().sampleRate;
this.noiseBuffer = Tone.getContext().createBuffer(1, sampleRate * 0.5, sampleRate);
const data = this.noiseBuffer.getChannelData(0);
for (let i = 0; i < data.length; i++) data[i] = Math.random() * 2 - 1;
this.noiseBuffer = generateWhiteNoise(Tone.getContext(), 2.0);
}

trigger(time: number, pitch: number, decay: number, velocity: number = 0.8) {
const noiseSrc = new Tone.BufferSource(this.noiseBuffer);
const randomStart = Math.random() * (this.noiseBuffer.duration - 0.6);
const bpfFreq = (1000 + pitch * 1000);
const bpf = new Tone.Filter(applyVariance(bpfFreq, 0.02), "bandpass");
const gain = new Tone.Gain(0).connect(this.destination);
Expand All @@ -39,7 +37,7 @@ export class TR808Clap {
gain.gain.setValueAtTime(velocity, finalDecayStart);
gain.gain.exponentialRampToValueAtTime(0.001, finalDecayStart + decayTime);

noiseSrc.start(time).stop(finalDecayStart + decayTime);
noiseSrc.start(time, randomStart).stop(finalDecayStart + decayTime);

noiseSrc.onended = () => {
noiseSrc.dispose();
Expand Down
11 changes: 9 additions & 2 deletions src/logic/drums/TR808Cowbell.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ export class TR808Cowbell {
constructor(private destination: Tone.ToneAudioNode) { }

trigger(time: number, pitch: number, decay: number, velocity: number = 0.8) {
if (velocity <= 0) return;

// Core: Dual square wave oscillators from Schmitt trigger matrix
// Standard frequencies: 540Hz and 800Hz
const pitchMultiplier = 0.5 + pitch; // Range approx 0.5x to 1.5x
Expand All @@ -15,6 +17,8 @@ export class TR808Cowbell {

const osc1 = new Tone.Oscillator(freq1, "square");
const osc2 = new Tone.Oscillator(freq2, "square");
osc1.phase = Math.random() * 360;
osc2.phase = Math.random() * 360;

const mixGain = new Tone.Gain(0.5);
const bpf = new Tone.Filter(applyVariance(800, 0.02), "bandpass");
Expand All @@ -30,15 +34,15 @@ export class TR808Cowbell {

const decayTime = applyVariance(0.1 + decay * 0.4, 0.02);

vca.gain.setValueAtTime(velocity, time);
vca.gain.setValueAtTime(Math.max(0.001, velocity), time);
vca.gain.exponentialRampToValueAtTime(0.001, time + decayTime);

this.activeGains.add(vca);

osc1.start(time).stop(time + decayTime);
osc2.start(time).stop(time + decayTime);

osc1.onstop = () => {
const onEnd = () => {
osc1.dispose();
osc2.dispose();
mixGain.dispose();
Expand All @@ -47,6 +51,9 @@ export class TR808Cowbell {
vca.dispose();
this.activeGains.delete(vca);
};

osc1.onstop = onEnd;
setTimeout(onEnd, (decayTime + 0.5) * 1000);
}

stop(time: number) {
Expand Down
17 changes: 11 additions & 6 deletions src/logic/drums/TR808HiHat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ export class TR808HiHat {
constructor(private destination: Tone.ToneAudioNode) { }

trigger(time: number, isOpen: boolean, pitch: number, decay: number, velocity: number = 0.8) {
if (velocity <= 0) return;

// Create nodes
const mixGain = new Tone.Gain(0.15);
const bpf1 = new Tone.Filter(3440, "bandpass");
Expand All @@ -22,7 +24,7 @@ export class TR808HiHat {
const oscillators = this.frequencies.map(freq => {
const driftedFreq = applyPitchDrift(freq * pitchMultiplier, 2.0); // +/- 2Hz drift for hats
const osc = new Tone.Oscillator(driftedFreq, "square");
osc.phase = Math.random() * 360;
osc.phase = Math.random() * 360; // Analog phase randomization
osc.connect(mixGain);
return osc;
});
Expand All @@ -36,7 +38,7 @@ export class TR808HiHat {
envGain.connect(hpf);
hpf.connect(this.destination);

// Filter Q values and randomization
// Filter Q values and randomization from research
bpf1.Q.value = 1.5;
bpf2.Q.value = 1.5;
bpf1.frequency.value = applyVariance(3440, 0.02);
Expand All @@ -48,7 +50,7 @@ export class TR808HiHat {
const decayTime = applyVariance(decayBase, 0.02);

// VCA Envelope
envGain.gain.setValueAtTime(velocity, time);
envGain.gain.setValueAtTime(Math.max(0.001, velocity), time);
envGain.gain.exponentialRampToValueAtTime(0.001, time + decayTime);

this.activeGains.add(envGain);
Expand All @@ -58,9 +60,8 @@ export class TR808HiHat {
osc.start(time).stop(time + decayTime);
});

// Disposal - Explicitly clean up all 11-12 nodes to prevent memory leaks
// We use the first oscillator's onstop event to trigger the cleanup
oscillators[0].onstop = () => {
// Disposal - Explicitly clean up nodes to prevent memory leaks
const onEnd = () => {
oscillators.forEach(o => o.dispose());
mixGain.dispose();
bpf1.dispose();
Expand All @@ -69,6 +70,10 @@ export class TR808HiHat {
hpf.dispose();
this.activeGains.delete(envGain);
};

// Use Tone.Oscillator.onstop for cleanup with a safety timeout
oscillators[0].onstop = onEnd;
setTimeout(onEnd, (decayTime + 0.5) * 1000);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Make the fallback cleanup idempotent

On a normal completed hi-hat hit, oscillators[0].onstop invokes onEnd at the scheduled stop time, and this timeout invokes the same cleanup again about 500 ms later. That means every hi-hat trigger attempts to dispose the same oscillators/filters/gain nodes twice (the same pattern was added in TR808Cowbell.ts), which can surface runtime errors or touch already-freed Tone nodes; guard onEnd with a boolean or clear the fallback timer when onstop fires.

Useful? React with 👍 / 👎.

}

stop(time: number) {
Expand Down
17 changes: 12 additions & 5 deletions src/logic/drums/TR808Kick.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,11 +30,18 @@ export class TR808Kick {
osc.frequency.setValueAtTime(startFreq, time);
osc.frequency.exponentialRampToValueAtTime(endFreq, time + 0.05);

// VCA Amp Envelope: Instant attack, adjustable exponential decay
masterGain.gain.setValueAtTime(velocity, time);
masterGain.gain.exponentialRampToValueAtTime(0.001, time + finalDecay);

osc.start(time).stop(time + finalDecay);
// VCA Amp Envelope: Two-stage decay to emulate diode damping
// Stage 1: Fast damping (20ms) to 50% volume
// Stage 2: Natural decay to 0
const dampingTime = 0.02;
const dampingValue = Math.max(0.001, velocity * 0.5);
const safeFinalDecay = Math.max(dampingTime + 0.01, finalDecay);

masterGain.gain.setValueAtTime(Math.max(0.001, velocity), time);
masterGain.gain.exponentialRampToValueAtTime(dampingValue, time + dampingTime);
masterGain.gain.exponentialRampToValueAtTime(0.001, time + safeFinalDecay);

osc.start(time).stop(time + safeFinalDecay);

osc.onstop = () => {
osc.dispose();
Expand Down
13 changes: 4 additions & 9 deletions src/logic/drums/TR808Snare.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,11 @@
import * as Tone from 'tone'
import { applyPitchDrift, applyVariance } from '../DrumUtils'
import { applyPitchDrift, applyVariance, generateWhiteNoise } from '../DrumUtils'

export class TR808Snare {
private noiseBuffer: AudioBuffer;

constructor(private destination: Tone.ToneAudioNode) {
const sampleRate = Tone.getContext().sampleRate;
const bufferSize = sampleRate * 0.5; // 500ms
this.noiseBuffer = Tone.getContext().createBuffer(1, bufferSize, sampleRate);
const data = this.noiseBuffer.getChannelData(0);
for (let i = 0; i < data.length; i++) {
data[i] = Math.random() * 2 - 1;
}
this.noiseBuffer = generateWhiteNoise(Tone.getContext(), 2.0);
}

trigger(time: number, pitch: number, snappy: number, velocity: number = 0.8) {
Expand Down Expand Up @@ -48,6 +42,7 @@ export class TR808Snare {

// Snappy Layer
const noiseSrc = new Tone.BufferSource(this.noiseBuffer);
const randomStart = Math.random() * (this.noiseBuffer.duration - 0.5);
// High-pass filter (>1800Hz) to prevent phase trap with tonal body
// Q = 0.707 (Butterworth)
const noiseFilter = new Tone.Filter({
Expand All @@ -66,7 +61,7 @@ export class TR808Snare {

oscLow.start(time).stop(time + vcaDecay);
oscHigh.start(time).stop(time + vcaDecay);
noiseSrc.start(time).stop(time + snappyDecay + 0.1);
noiseSrc.start(time, randomStart).stop(time + snappyDecay + 0.1);

// Cleanup
oscLow.onstop = () => {
Expand Down
15 changes: 5 additions & 10 deletions src/logic/drums/TR909Kick.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import * as Tone from 'tone'
import { makeDistortionCurve, applyPitchDrift, applyVariance } from '../DrumUtils'
import { makeDistortionCurve, applyPitchDrift, applyVariance, generateWhiteNoise } from '../DrumUtils'

export class TR909Kick {
private noiseBuffer: AudioBuffer;
Expand All @@ -8,14 +8,7 @@ export class TR909Kick {
constructor(private destination: Tone.ToneAudioNode) {
// Soft Clipping curve from research
this.bodyCurve = makeDistortionCurve(10);

const sampleRate = Tone.getContext().sampleRate;
const bufferSize = sampleRate * 0.05; // 50ms click
this.noiseBuffer = Tone.getContext().createBuffer(1, bufferSize, sampleRate);
const data = this.noiseBuffer.getChannelData(0);
for (let i = 0; i < bufferSize; i++) {
data[i] = Math.random() * 2 - 1;
}
this.noiseBuffer = generateWhiteNoise(Tone.getContext(), 2.0);
}

trigger(time: number, pitch: number, decay: number, velocity: number = 0.8) {
Expand Down Expand Up @@ -58,6 +51,8 @@ export class TR909Kick {

// Click Layer (Noise)
const noiseSrc = new Tone.BufferSource(this.noiseBuffer);
// Avoid "machine-gun" effect by randomizing start offset
const randomStart = Math.random() * (this.noiseBuffer.duration - 0.1);
const noiseFilter = new Tone.Filter(noiseFilterFreq, "highpass"); // HPF > 1kHz to avoid phase trap
const noiseGain = new Tone.Gain(0);

Expand All @@ -80,7 +75,7 @@ export class TR909Kick {
pulseGain.gain.exponentialRampToValueAtTime(0.001, time + 0.005);

bodyOsc.start(time).stop(time + vcaDecay);
noiseSrc.start(time).stop(time + clickDecay);
noiseSrc.start(time, randomStart).stop(time + clickDecay);
pulseOsc.start(time).stop(time + 0.005);

bodyOsc.onstop = () => {
Expand Down
15 changes: 4 additions & 11 deletions src/logic/drums/TR909Snare.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import * as Tone from 'tone'
import { makeDistortionCurve, applyPitchDrift, applyVariance } from '../DrumUtils'
import { makeDistortionCurve, applyPitchDrift, applyVariance, generateLFSRNoise } from '../DrumUtils'

export class TR909Snare {
private noiseBuffer: AudioBuffer;
Expand All @@ -8,15 +8,7 @@ export class TR909Snare {
constructor(private destination: Tone.ToneAudioNode) {
// Soft Clipping curve from research
this.bodyCurve = makeDistortionCurve(15);

const sampleRate = Tone.getContext().sampleRate;
const bufferSize = sampleRate * 0.5;
this.noiseBuffer = Tone.getContext().createBuffer(1, bufferSize, sampleRate);
const data = this.noiseBuffer.getChannelData(0);
// While original used LFSR, research says Math.random() is sufficient for Web Audio API context
for (let i = 0; i < data.length; i++) {
data[i] = Math.random() * 2 - 1;
}
this.noiseBuffer = generateLFSRNoise(Tone.getContext(), 2.0);
}

trigger(time: number, pitch: number, snappy: number, velocity: number = 0.8) {
Expand Down Expand Up @@ -65,6 +57,7 @@ export class TR909Snare {

// Snappy Layer
const noiseSrc = new Tone.BufferSource(this.noiseBuffer);
const randomStart = Math.random() * (this.noiseBuffer.duration - 0.5);
const hpf = new Tone.Filter(noiseHPFFreq, "highpass"); // HPF to protect fundamental
// LPF controlled by 'Tone' (pitch parameter here), range 4kHz to 8kHz (research: toneCutoff)
const toneCutoff = 4000 + pitch * 4000;
Expand All @@ -81,7 +74,7 @@ export class TR909Snare {

osc1.start(time).stop(time + vcaDecay);
osc2.start(time).stop(time + vcaDecay);
noiseSrc.start(time).stop(time + snappyDecay + 0.1);
noiseSrc.start(time, randomStart).stop(time + snappyDecay + 0.1);

osc1.onstop = () => {
osc1.dispose();
Expand Down
12 changes: 12 additions & 0 deletions src/store/audioStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import * as Tone from 'tone'
import { AcidSynth } from '../logic/AcidSynth'
import { DrumMachine } from '../logic/DrumMachine'
import { PadSynth } from '../logic/PadSynth'
import { useDrumStore } from './instrumentStore'

export interface AudioState {
isInitialized: boolean
Expand Down Expand Up @@ -60,6 +61,17 @@ export const useAudioStore = create<AudioState>((set, get) => ({
const drums = new DrumMachine()
const pads = new PadSynth()

// Sync initial drum parameters from instrumentStore
const drumStore = useDrumStore.getState()
drums.syncInternalParams(drumStore.kit, drumStore.drive, {
kick: drumStore.kick,
snare: drumStore.snare,
hihat: drumStore.hihat,
hihatOpen: drumStore.hihatOpen,
clap: drumStore.clap,
cowbell: drumStore.cowbell
})

Tone.Transport.bpm.value = get().bpm
Tone.Transport.swing = get().swing

Expand Down