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
46 changes: 46 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,47 @@ 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 AudioBuffer of 15-bit LFSR noise (pseudo-random sequence).
* Characteristic polynomial: x^15 + x^14 + 1.
* @param context - Tone.BaseContext
* @param duration - Duration in seconds (default 2.0s)
*/
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 register, non-zero start

for (let i = 0; i < bufferSize; i++) {
// x^15 + x^14 + 1 => bits 14 and 13 (0-indexed)
const bit = ((lfsr >> 14) ^ (lfsr >> 13)) & 1;
lfsr = ((lfsr << 1) | bit) & 0x7FFF;

// Convert to range [-1.0, 1.0]
data[i] = (bit * 2) - 1;
}

return buffer;
}

/**
* Generates an AudioBuffer of standard white noise.
* @param context - Tone.BaseContext
* @param duration - Duration in seconds (default 2.0s)
*/
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;
}
23 changes: 14 additions & 9 deletions src/logic/drums/TR808Clap.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,17 @@
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(), 0.5);
}

trigger(time: number, pitch: number, decay: number, velocity: number = 0.8) {
// Early exit if velocity is zero to optimize CPU
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 @@ -20,26 +20,31 @@ export class TR808Clap {
noiseSrc.connect(bpf);
bpf.connect(gain);

const safeVelocity = Math.max(0.001, velocity);

// Triple attack "snaps"
const snapCount = 3;
const snapIntervalBase = 0.01;
const snapInterval = applyVariance(snapIntervalBase, 0.02);

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.setValueAtTime(safeVelocity, snapTime);
gain.gain.exponentialRampToValueAtTime(safeVelocity * 0.1, snapTime + snapInterval * 0.8);
}

// Final decay
const finalDecayStart = time + snapCount * snapInterval;
const decayTimeBase = 0.1 + decay * 0.5;
const decayTime = applyVariance(decayTimeBase, 0.02);

gain.gain.setValueAtTime(velocity, finalDecayStart);
// Use random offset within noise buffer for unique hits
const randomStart = Math.random() * (this.noiseBuffer.duration - decayTime - 0.1);

gain.gain.setValueAtTime(safeVelocity, 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
6 changes: 5 additions & 1 deletion src/logic/drums/TR808Cowbell.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@ export class TR808Cowbell {
constructor(private destination: Tone.ToneAudioNode) { }

trigger(time: number, pitch: number, decay: number, velocity: number = 0.8) {
// Early exit if velocity is zero to optimize CPU
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 @@ -29,8 +32,9 @@ export class TR808Cowbell {
vca.connect(this.destination);

const decayTime = applyVariance(0.1 + decay * 0.4, 0.02);
const safeVelocity = Math.max(0.001, velocity);

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

this.activeGains.add(vca);
Expand Down
6 changes: 5 additions & 1 deletion src/logic/drums/TR808HiHat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@ export class TR808HiHat {
constructor(private destination: Tone.ToneAudioNode) { }

trigger(time: number, isOpen: boolean, pitch: number, decay: number, velocity: number = 0.8) {
// Early exit if velocity is zero to optimize CPU
if (velocity <= 0) return;

// Create nodes
const mixGain = new Tone.Gain(0.15);
const bpf1 = new Tone.Filter(3440, "bandpass");
Expand Down Expand Up @@ -46,9 +49,10 @@ export class TR808HiHat {
// 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);
const safeVelocity = Math.max(0.001, velocity);

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

this.activeGains.add(envGain);
Expand Down
19 changes: 14 additions & 5 deletions src/logic/drums/TR808Kick.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@ export class TR808Kick {
constructor(private destination: Tone.ToneAudioNode) { }

trigger(time: number, pitch: number, decay: number, velocity: number = 0.8) {
// Early exit if velocity is zero to optimize CPU
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 +26,24 @@ 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
masterGain.gain.setValueAtTime(velocity, time);
masterGain.gain.exponentialRampToValueAtTime(0.001, time + finalDecay);
// VCA Amp Envelope: Two-stage decay for diode damping emulation
// Stage 1: fast 20ms initial decay to 50% volume
// Stage 2: long adjustable exponential decay
const safeVelocity = Math.max(0.001, velocity);
masterGain.gain.setValueAtTime(safeVelocity, time);
masterGain.gain.exponentialRampToValueAtTime(safeVelocity * 0.5, time + 0.02);

// Ensure the final decay is always longer than the damping stage
const safeFinalDecay = Math.max(0.03, 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
26 changes: 14 additions & 12 deletions src/logic/drums/TR808Snare.ts
Original file line number Diff line number Diff line change
@@ -1,20 +1,17 @@
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(), 0.5);
}

trigger(time: number, pitch: number, snappy: number, velocity: number = 0.8) {
// Early exit if velocity is zero to optimize CPU
if (velocity <= 0) return;

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

Expand All @@ -38,12 +35,14 @@ export class TR808Snare {
gainLow.connect(this.destination);
gainHigh.connect(this.destination);

const safeVelocity = Math.max(0.001, velocity);

// Independent envelopes for maximum authenticity as per research
// High mode decays faster (approx 75% of low mode decay)
gainLow.gain.setValueAtTime(velocity * (1 - toneBalance), time);
gainLow.gain.setValueAtTime(safeVelocity * (1 - toneBalance), time);
gainLow.gain.exponentialRampToValueAtTime(0.001, time + vcaDecay);

gainHigh.gain.setValueAtTime(velocity * toneBalance, time);
gainHigh.gain.setValueAtTime(safeVelocity * toneBalance, time);
gainHigh.gain.exponentialRampToValueAtTime(0.001, time + vcaDecay * 0.75);

// Snappy Layer
Expand All @@ -61,12 +60,15 @@ export class TR808Snare {
noiseFilter.connect(snappyGain);
snappyGain.connect(this.destination);

snappyGain.gain.setValueAtTime(velocity * 0.8, time);
// Use random offset within noise buffer for unique hits
const randomStart = Math.random() * (this.noiseBuffer.duration - snappyDecay - 0.1);

snappyGain.gain.setValueAtTime(safeVelocity * 0.8, time);
snappyGain.gain.exponentialRampToValueAtTime(0.001, time + snappyDecay);

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
26 changes: 13 additions & 13 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,17 +8,13 @@ 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(), 0.05);
}

trigger(time: number, pitch: number, decay: number, velocity: number = 0.8) {
// Early exit if velocity is zero to optimize CPU
if (velocity <= 0) return;

// 909 Kick base frequency is fixed around 50Hz
const tune = 50;
// The 'Pitch' parameter on 909 maps to the frequency sweep duration
Expand Down Expand Up @@ -53,7 +49,8 @@ export class TR909Kick {
bodyOsc.frequency.exponentialRampToValueAtTime(endFreq, time + sweepDuration);

// VCA Envelope
bodyGain.gain.setValueAtTime(velocity, time);
const safeVelocity = Math.max(0.001, velocity);
bodyGain.gain.setValueAtTime(safeVelocity, time);
bodyGain.gain.exponentialRampToValueAtTime(0.001, time + vcaDecay);

// Click Layer (Noise)
Expand All @@ -67,7 +64,10 @@ export class TR909Kick {

// Ultra short envelope (10-20ms) for the click
const clickDecay = applyVariance(0.02, 0.02);
noiseGain.gain.setValueAtTime(velocity * 0.7, time);
// Use random offset within noise buffer for unique hits
const randomStart = Math.random() * (this.noiseBuffer.duration - clickDecay - 0.01);

noiseGain.gain.setValueAtTime(safeVelocity * 0.7, time);
noiseGain.gain.exponentialRampToValueAtTime(0.001, time + clickDecay);

// Rectangular Pulse Click: Short 5ms impulse for attack articulation
Expand All @@ -76,11 +76,11 @@ export class TR909Kick {
pulseOsc.connect(pulseGain);
pulseGain.connect(this.destination);

pulseGain.gain.setValueAtTime(velocity * 0.5, time);
pulseGain.gain.setValueAtTime(safeVelocity * 0.5, time);
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
27 changes: 15 additions & 12 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 @@ -9,17 +9,14 @@ export class TR909Snare {
// 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;
}
// Authentic 15-bit LFSR noise for 909 'Snappy' component
this.noiseBuffer = generateLFSRNoise(Tone.getContext(), 0.5);
}

trigger(time: number, pitch: number, snappy: number, velocity: number = 0.8) {
// Early exit if velocity is zero to optimize CPU
if (velocity <= 0) return;

// 909 Snare Body: 2 triangle oscillators fixed at ~160Hz and ~220Hz
const freq1 = 160;
const freq2 = 220;
Expand All @@ -36,6 +33,7 @@ export class TR909Snare {
const osc2 = new Tone.Oscillator(toneDrift2 * 2, "triangle");
osc1.phase = Math.random() * 360;
osc2.phase = Math.random() * 360;

// Routing with gain compensation to prevent clipping before the shaper
const preShaperGain = new Tone.Gain(0.5);
const bodyShaper = new Tone.WaveShaper(this.bodyCurve);
Expand All @@ -60,12 +58,14 @@ export class TR909Snare {
osc2.frequency.setValueAtTime(startFreq2, time);
osc2.frequency.exponentialRampToValueAtTime(toneDrift2, time + sweepTime);

tonalGain.gain.setValueAtTime(velocity, time);
const safeVelocity = Math.max(0.001, velocity);
tonalGain.gain.setValueAtTime(safeVelocity, time);
tonalGain.gain.exponentialRampToValueAtTime(0.001, time + vcaDecay);

// Snappy Layer
const noiseSrc = new Tone.BufferSource(this.noiseBuffer);
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;
const lpf = new Tone.Filter(applyVariance(toneCutoff, 0.02), "lowpass");
Expand All @@ -76,12 +76,15 @@ export class TR909Snare {
lpf.connect(noiseGain);
noiseGain.connect(this.destination);

noiseGain.gain.setValueAtTime(velocity * 0.7, time);
// Use random offset within noise buffer for unique hits
const randomStart = Math.random() * (this.noiseBuffer.duration - snappyDecay - 0.1);

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 Clamp random noise offsets before starting buffers

With Snappy near 1, snappyDecay can reach the full 0.5s buffer length (and variance can push it higher), so this.noiseBuffer.duration - snappyDecay - 0.1 becomes negative and the value is passed as the BufferSource start offset below. Negative AudioBufferSource offsets are rejected at runtime, so high-snappy 909 snare hits can throw/drop instead of playing; clamp the random offset to at least 0 or make the generated buffer longer. The same pattern was added in the 808 clap/snare paths.

Useful? React with 👍 / 👎.


noiseGain.gain.setValueAtTime(safeVelocity * 0.7, time);
noiseGain.gain.exponentialRampToValueAtTime(0.001, time + snappyDecay);

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