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 the store's state.
*/
syncInternalParams(kit: '808' | '909', drive: number, drumParams: any) {
this.setKit(kit);
this.setSaturation(drive);
for (const drumId in drumParams) {
this.setDrumParams(drumId, drumParams[drumId].pitch, drumParams[drumId].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
49 changes: 43 additions & 6 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 All @@ -21,13 +23,14 @@ export function makeDistortionCurve(amount: number = 20): Float32Array {
}

/**
* Applies micro-randomization to a base frequency (Pitch Drift).
* Typically +/- 1Hz as per research.
* Applies micro-randomization to a base frequency (Pitch Drift) using cents.
* Formula: f_new = f_base * Math.pow(2, cents / 1200)
* @param base - Base frequency in Hz
* @param range - Drift range in Hz (default 1.0)
* @param centsRange - Drift range in cents (default 1.0)
*/
export function applyPitchDrift(base: number, range: number = 1.0): number {
return base + (Math.random() * 2 - 1) * range;
export function applyPitchDrift(base: number, centsRange: number = 1.0): number {
const drift = (Math.random() * 2 - 1) * centsRange;
return base * Math.pow(2, drift / 1200);
}

/**
Expand All @@ -37,6 +40,40 @@ export function applyPitchDrift(base: number, range: number = 1.0): number {
* @param variance - Variance percentage (e.g. 0.02 for 2%)
*/
export function applyVariance(base: number, variance: number = 0.02): number {
// Math.random() * 0.04 - 0.02 gives range [-0.02, 0.02]
// Math.random() * (variance * 2) - variance gives range [-variance, variance]
return base * (1 + (Math.random() * (variance * 2) - variance));
}

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

/**
* Generates a 2-second buffer of 15-bit LFSR (Linear Feedback Shift Register) noise.
* This provides an authentic "digital" texture for TR-909 snappy components.
* @param context - Audio context
*/
export function generateLFSRNoise(context: Tone.BaseContext): AudioBuffer {
const bufferSize = context.sampleRate * 2.0;
const buffer = context.createBuffer(1, bufferSize, context.sampleRate);
const data = buffer.getChannelData(0);
let lfsr = 0x7FFF; // 15-bit register, non-zero start

for (let i = 0; i < bufferSize; i++) {
// 15-bit LFSR with taps at bits 14 and 15 (x^15 + x^14 + 1)
const bit = ((lfsr >> 0) ^ (lfsr >> 1)) & 1;
lfsr = (lfsr >> 1) | (bit << 14);
data[i] = ((lfsr & 1) * 2 - 1) * 0.5; // Scaled to [-0.5, 0.5] for texture
}
return buffer;
}
20 changes: 11 additions & 9 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 @@ -25,21 +24,24 @@ export class TR808Clap {
const snapIntervalBase = 0.01;
const snapInterval = applyVariance(snapIntervalBase, 0.02);

const v = Math.max(0.001, velocity);
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(v, snapTime);
gain.gain.exponentialRampToValueAtTime(Math.max(0.001, v * 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);
gain.gain.setValueAtTime(v, finalDecayStart);
gain.gain.exponentialRampToValueAtTime(0.001, finalDecayStart + decayTime);

noiseSrc.start(time).stop(finalDecayStart + decayTime);
// Random start offset to avoid machine-gun effect
const randomStart = Math.random() * (this.noiseBuffer.duration - (finalDecayStart - time) - decayTime - 0.1);
noiseSrc.start(time, randomStart).stop(finalDecayStart + decayTime);

noiseSrc.onended = () => {
noiseSrc.dispose();
Expand Down
5 changes: 4 additions & 1 deletion 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 @@ -30,7 +32,8 @@ export class TR808Cowbell {

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

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

this.activeGains.add(vca);
Expand Down
11 changes: 6 additions & 5 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 @@ -20,15 +22,14 @@ 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); // 2 cents drift
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);
Expand All @@ -48,7 +49,8 @@ export class TR808HiHat {
const decayTime = applyVariance(decayBase, 0.02);

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

this.activeGains.add(envGain);
Expand All @@ -58,8 +60,7 @@ 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
// Disposal
oscillators[0].onstop = () => {
oscillators.forEach(o => o.dispose());
mixGain.dispose();
Expand Down
22 changes: 16 additions & 6 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 @@ -19,22 +21,30 @@ export class TR808Kick {
masterGain.connect(this.destination);

// Micro-randomization using shared utilities
const tuneDrift = applyPitchDrift(tune, 1.0);
const tuneDrift = applyPitchDrift(tune, 2.0); // 2 cents drift
const finalDecay = applyVariance(decayTime, 0.02);

// Two-stage damping requires the final decay to be longer than the damping stage (20ms)
const safeFinalDecay = Math.max(finalDecay, 0.03);

// 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 damping emulating diode behavior
const v = Math.max(0.001, velocity);
masterGain.gain.setValueAtTime(v, time);

// Stage 1: Fast initial damping (20ms) to 50% volume
masterGain.gain.exponentialRampToValueAtTime(Math.max(0.001, v * 0.5), time + 0.02);

// Stage 2: Main exponential decay
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
29 changes: 14 additions & 15 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 All @@ -25,8 +21,8 @@ export class TR808Snare {
const noiseFilterFreq = applyVariance(1800, 0.02);

// 808 Membrane modes: fixed at ~238Hz and ~476Hz according to research
const oscLow = new Tone.Oscillator(applyPitchDrift(238, 1.0), "sine");
const oscHigh = new Tone.Oscillator(applyPitchDrift(476, 1.0), "sine");
const oscLow = new Tone.Oscillator(applyPitchDrift(238, 2.0), "sine");
const oscHigh = new Tone.Oscillator(applyPitchDrift(476, 2.0), "sine");
oscLow.phase = Math.random() * 360;
oscHigh.phase = Math.random() * 360;

Expand All @@ -40,16 +36,19 @@ export class TR808Snare {

// 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);
const v = Math.max(0.001, velocity);
gainLow.gain.setValueAtTime(v * (1 - toneBalance), time);
gainLow.gain.exponentialRampToValueAtTime(0.001, time + vcaDecay);

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

// Snappy Layer
const noiseSrc = new Tone.BufferSource(this.noiseBuffer);
// Random start offset to avoid machine-gun effect
const randomStart = Math.random() * (this.noiseBuffer.duration - snappyDecay - 0.1);

// 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 @@ -61,12 +60,12 @@ export class TR808Snare {
noiseFilter.connect(snappyGain);
snappyGain.connect(this.destination);

snappyGain.gain.setValueAtTime(velocity * 0.8, time);
snappyGain.gain.setValueAtTime(v * 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: 12 additions & 14 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,12 @@ 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());
}

trigger(time: number, pitch: number, decay: number, velocity: number = 0.8) {
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 All @@ -28,7 +23,7 @@ export class TR909Kick {
const decayTime = 0.3 + decay * 0.3;

// Micro-randomization using shared utilities
const tuneDrift = applyPitchDrift(tune, 0.5);
const tuneDrift = applyPitchDrift(tune, 0.5); // 0.5 cents drift
const vcaDecay = applyVariance(decayTime, 0.02);
const noiseFilterFreq = applyVariance(1000, 0.02);

Expand All @@ -53,11 +48,14 @@ export class TR909Kick {
bodyOsc.frequency.exponentialRampToValueAtTime(endFreq, time + sweepDuration);

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

// Click Layer (Noise)
const noiseSrc = new Tone.BufferSource(this.noiseBuffer);
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 @@ -67,7 +65,7 @@ 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);
noiseGain.gain.setValueAtTime(v * 0.7, time);
noiseGain.gain.exponentialRampToValueAtTime(0.001, time + clickDecay);

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

pulseGain.gain.setValueAtTime(velocity * 0.5, time);
pulseGain.gain.setValueAtTime(v * 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
Loading