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
37 changes: 37 additions & 0 deletions src/logic/DrumUtils.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import * as Tone from 'tone'

/**
* Shared DSP utilities for drum synthesis based on research specs.
*/
Expand Down Expand Up @@ -40,3 +42,38 @@ export function applyVariance(base: number, variance: number = 0.02): number {
// Math.random() * 0.04 - 0.02 gives range [-0.02, 0.02]
return base * (1 + (Math.random() * (variance * 2) - variance));
}

/**
* Generates an authentic 15-bit LFSR noise buffer (TR-909 style).
* Characteristic polynomial: x^15 + x^14 + 1
*/
export function generateLFSRNoise(context: Tone.BaseContext): AudioBuffer {
const sampleRate = context.sampleRate;
const bufferSize = sampleRate * 2.0; // 2 seconds
const buffer = context.createBuffer(1, bufferSize, sampleRate);
const data = buffer.getChannelData(0);

let lfsr = 0x7FFF; // Initial state

for (let i = 0; i < bufferSize; i++) {
// Tap bits 14 and 15 (indices 13 and 14)
const bit = ((lfsr >> 14) ^ (lfsr >> 13)) & 1;
lfsr = ((lfsr << 1) | bit) & 0x7FFF;
data[i] = (bit * 2 - 1) * 0.5;
}
return buffer;
}

/**
* Generates a standard white noise buffer.
*/
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;
}
15 changes: 8 additions & 7 deletions src/logic/drums/TR808Clap.ts
Original file line number Diff line number Diff line change
@@ -1,17 +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());
}

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

const noiseSrc = new Tone.BufferSource(this.noiseBuffer);
const bpfFreq = (1000 + pitch * 1000);
const bpf = new Tone.Filter(applyVariance(bpfFreq, 0.02), "bandpass");
Expand All @@ -28,7 +27,7 @@ export class TR808Clap {
for (let i = 0; i < snapCount; i++) {
const snapTime = time + i * snapInterval;
gain.gain.setValueAtTime(velocity, snapTime);
gain.gain.exponentialRampToValueAtTime(velocity * 0.1, snapTime + snapInterval * 0.8);
gain.gain.exponentialRampToValueAtTime(Math.max(0.001, velocity * 0.1), snapTime + snapInterval * 0.8);
}

// Final decay
Expand All @@ -39,7 +38,9 @@ export class TR808Clap {
gain.gain.setValueAtTime(velocity, finalDecayStart);
gain.gain.exponentialRampToValueAtTime(0.001, finalDecayStart + decayTime);

noiseSrc.start(time).stop(finalDecayStart + decayTime);
const randomStart = Math.random() * (this.noiseBuffer.duration - decayTime - 0.2);
noiseSrc.start(time, randomStart);
noiseSrc.stop(finalDecayStart + decayTime + 0.1);

noiseSrc.onended = () => {
noiseSrc.dispose();
Expand Down
29 changes: 19 additions & 10 deletions src/logic/drums/TR808Cowbell.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,16 @@ import * as Tone from 'tone'
import { applyPitchDrift, applyVariance } from '../DrumUtils'

export class TR808Cowbell {
private activeGains: Set<Tone.Gain> = new Set();
// Track active voices for clean termination
private activeVoices: Set<{ oscillators: Tone.Oscillator[], gain: Tone.Gain }> = new Set();

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
const pitchMultiplier = 0.5 + pitch;
const freq1 = applyPitchDrift(540 * pitchMultiplier, 2.0);
const freq2 = applyPitchDrift(800 * pitchMultiplier, 2.0);

Expand All @@ -33,27 +35,34 @@ export class TR808Cowbell {
vca.gain.setValueAtTime(velocity, time);
vca.gain.exponentialRampToValueAtTime(0.001, time + decayTime);

this.activeGains.add(vca);
const voice = { oscillators: [osc1, osc2], gain: vca };
this.activeVoices.add(voice);

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

osc1.onstop = () => {
const cleanup = () => {
osc1.dispose();
osc2.dispose();
mixGain.dispose();
bpf.dispose();
hpf.dispose();
vca.dispose();
this.activeGains.delete(vca);
this.activeVoices.delete(voice);
};

osc1.onstop = cleanup;
setTimeout(cleanup, (decayTime + 0.1) * 1000);
}

stop(time: number) {
this.activeGains.forEach(vca => {
vca.gain.cancelScheduledValues(time);
vca.gain.exponentialRampToValueAtTime(0.001, time + 0.02);
this.activeVoices.forEach(voice => {
voice.oscillators.forEach(osc => {
osc.stop(time);
});
voice.gain.gain.cancelScheduledValues(time);
voice.gain.gain.exponentialRampToValueAtTime(0.001, time + 0.02);
});
this.activeGains.clear();
this.activeVoices.clear();
}
}
37 changes: 21 additions & 16 deletions src/logic/drums/TR808HiHat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,14 @@ import { applyPitchDrift, applyVariance } from '../DrumUtils'

export class TR808HiHat {
private frequencies = [205.3, 304.4, 369.6, 522.7, 800, 540];
private activeGains: Set<Tone.Gain> = new Set();
// Track active voices for clean termination/choking
private activeVoices: Set<{ oscillators: Tone.Oscillator[], gain: Tone.Gain }> = new Set();

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 @@ -20,62 +23,64 @@ export class TR808HiHat {

// Create 6 Square Wave Oscillators (Schmitt Trigger Matrix)
const oscillators = this.frequencies.map(freq => {
const driftedFreq = applyPitchDrift(freq * pitchMultiplier, 2.0); // +/- 2Hz drift for hats
const driftedFreq = applyPitchDrift(freq * pitchMultiplier, 2.0);
const osc = new Tone.Oscillator(driftedFreq, "square");
osc.phase = Math.random() * 360;
osc.connect(mixGain);
return osc;
});

// Routing Graph
// Oscillators -> MixGain -> [BPF1, BPF2] (Parallel) -> EnvGain -> HPF -> Destination
mixGain.connect(bpf1);
mixGain.connect(bpf2);
bpf1.connect(envGain);
bpf2.connect(envGain);
envGain.connect(hpf);
hpf.connect(this.destination);

// Filter Q values and randomization
bpf1.Q.value = 1.5;
bpf2.Q.value = 1.5;
bpf1.frequency.value = applyVariance(3440, 0.02);
bpf2.frequency.value = applyVariance(7100, 0.02);
hpf.frequency.value = applyVariance(7000, 0.02);

// Decay: Closed Hat (40-60ms), Open Hat (300-500ms)
const decayBase = isOpen ? (0.3 + decay * 0.2) : (0.04 + decay * 0.02);
const decayTime = applyVariance(decayBase, 0.02);

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

this.activeGains.add(envGain);
const voice = { oscillators, gain: envGain };
this.activeVoices.add(voice);

// Scheduling
oscillators.forEach(osc => {
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 and Cleanup
const cleanup = () => {
oscillators.forEach(o => o.dispose());
mixGain.dispose();
bpf1.dispose();
bpf2.dispose();
envGain.dispose();
hpf.dispose();
this.activeGains.delete(envGain);
this.activeVoices.delete(voice);
};

// Use setTimeout as a safety net if onstop is not reliable in all environments
oscillators[0].onstop = cleanup;
setTimeout(cleanup, (decayTime + 0.1) * 1000);
}

stop(time: number) {
this.activeGains.forEach(gain => {
gain.gain.cancelScheduledValues(time);
gain.gain.exponentialRampToValueAtTime(0.001, time + 0.02);
this.activeVoices.forEach(voice => {
voice.oscillators.forEach(osc => {
osc.stop(time);

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 Let hihat chokes fade before stopping sources

When a closed hat triggers, DrumMachine calls hihatOpen.stop(time) (src/logic/DrumMachine.ts:112/124) to choke the open hat. Stopping each oscillator exactly at time means the subsequent gain.exponentialRampToValueAtTime(..., time + 0.02) has no audio left to fade, so every choke becomes an instantaneous cut at an arbitrary oscillator phase and can click; stop the sources after the fade or leave them running through the gain ramp.

Useful? React with 👍 / 👎.

});
voice.gain.gain.cancelScheduledValues(time);
voice.gain.gain.exponentialRampToValueAtTime(0.001, time + 0.02);
});
this.activeGains.clear();
this.activeVoices.clear();
}
}
15 changes: 11 additions & 4 deletions src/logic/drums/TR808Kick.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ export class TR808Kick {
constructor(private destination: Tone.ToneAudioNode) { }

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

// pitch: 0.5 -> 52.5Hz, maps to 45-60Hz range
const tune = 45 + pitch * 15;
// decay: 0.5 -> 1.7s, maps to 0.4-3.0s range
Expand All @@ -23,18 +25,23 @@ export class TR808Kick {
const finalDecay = applyVariance(decayTime, 0.02);

// Pitch Envelope: Start high (Tune * 2.5) and drop quickly (50ms) to simulate the membrane hit ('tonk')
// This rapid sweep generates the punch without needing a separate click oscillator
const startFreq = tuneDrift * 2.5;
const endFreq = tuneDrift;

osc.frequency.setValueAtTime(startFreq, time);
osc.frequency.exponentialRampToValueAtTime(endFreq, time + 0.05);

// VCA Amp Envelope: Instant attack, adjustable exponential decay
// VCA Amp Envelope with Diode Damping emulation
// Fast initial decay (20ms) to 50% volume followed by the main decay
const dampingTime = 0.02;
masterGain.gain.setValueAtTime(velocity, time);
masterGain.gain.exponentialRampToValueAtTime(0.001, time + finalDecay);
masterGain.gain.exponentialRampToValueAtTime(velocity * 0.5, time + dampingTime);

// Ensure final decay is always longer than damping stage
const safeFinalDecay = Math.max(dampingTime + 0.01, finalDecay);
masterGain.gain.exponentialRampToValueAtTime(0.001, time + safeFinalDecay);

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

osc.onstop = () => {
osc.dispose();
Expand Down
18 changes: 8 additions & 10 deletions src/logic/drums/TR808Snare.ts
Original file line number Diff line number Diff line change
@@ -1,20 +1,16 @@
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());
}

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

// pitch maps to tone balance here (balance between low and high modes)
const toneBalance = pitch;

Expand Down Expand Up @@ -49,7 +45,6 @@ export class TR808Snare {
// Snappy Layer
const noiseSrc = new Tone.BufferSource(this.noiseBuffer);
// High-pass filter (>1800Hz) to prevent phase trap with tonal body
// Q = 0.707 (Butterworth)
const noiseFilter = new Tone.Filter({
frequency: noiseFilterFreq,
type: "highpass",
Expand All @@ -64,9 +59,12 @@ export class TR808Snare {
snappyGain.gain.setValueAtTime(velocity * 0.8, time);
snappyGain.gain.exponentialRampToValueAtTime(0.001, time + snappyDecay);

const randomStart = Math.random() * (this.noiseBuffer.duration - snappyDecay - 0.1);
noiseSrc.start(time, randomStart);
noiseSrc.stop(time + snappyDecay + 0.1);

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

// Cleanup
oscLow.onstop = () => {
Expand Down
Loading