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
8 changes: 8 additions & 0 deletions src/logic/DrumMachine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,14 @@ export class DrumMachine {
}
}

syncInternalParams(kit: '808' | '909', drive: number, drumParams: Record<string, { pitch: number, decay: number }>) {
this.setKit(kit)
this.setSaturation(drive)
for (const [drum, p] of Object.entries(drumParams)) {
this.setDrumParams(drum, p.pitch, p.decay)
}
}

setSaturation(amount: number) {
this.shaper.curve = makeDistortionCurve(amount)
}
Expand Down
49 changes: 42 additions & 7 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 cents = (Math.random() * 2 - 1) * centsRange;
return base * Math.pow(2, cents / 1200);
}

/**
Expand All @@ -37,6 +40,38 @@ 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]
return base * (1 + (Math.random() * (variance * 2) - variance));
// Math.random() * 2 - 1 gives range [-1, 1]
return base * (1 + (Math.random() * 2 - 1) * variance);
}

/**
* Generates a 2.0s white noise buffer.
*/
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 an authentic 15-bit LFSR pseudo-random noise sequence.
* Uses characteristic polynomial x^15 + x^14 + 1.
*/
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 reg = 0x7FFF; // 15-bit register initialized to all ones

for (let i = 0; i < bufferSize; i++) {
// Feedback bit is bit 14 XOR bit 13 (zero-indexed)
const bit = ((reg >> 14) ^ (reg >> 13)) & 1;
reg = ((reg << 1) | bit) & 0x7FFF;
data[i] = (reg / 16384.0) - 1.0; // Normalize to [-1.0, 1.0]
}
return buffer;
}
12 changes: 6 additions & 6 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());
}

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

const randomStart = Math.random() * (this.noiseBuffer.duration - 0.6);
const noiseSrc = new Tone.BufferSource(this.noiseBuffer);
const bpfFreq = (1000 + pitch * 1000);
const bpf = new Tone.Filter(applyVariance(bpfFreq, 0.02), "bandpass");
Expand Down Expand Up @@ -39,7 +39,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
19 changes: 11 additions & 8 deletions src/logic/drums/TR808Cowbell.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,14 @@ import * as Tone from 'tone'
import { applyPitchDrift, applyVariance } from '../DrumUtils'

export class TR808Cowbell {
private activeGains: Set<Tone.Gain> = new Set();
private activeVoices: Set<{ oscillators: Tone.Oscillator[], vca: 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 freq1 = applyPitchDrift(540 * pitchMultiplier, 2.0);
const freq2 = applyPitchDrift(800 * pitchMultiplier, 2.0);
Expand All @@ -33,7 +34,8 @@ export class TR808Cowbell {
vca.gain.setValueAtTime(velocity, time);
vca.gain.exponentialRampToValueAtTime(0.001, time + decayTime);

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

osc1.start(time).stop(time + decayTime);
osc2.start(time).stop(time + decayTime);
Expand All @@ -45,15 +47,16 @@ export class TR808Cowbell {
bpf.dispose();
hpf.dispose();
vca.dispose();
this.activeGains.delete(vca);
this.activeVoices.delete(voice);
};
}

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.vca.gain.cancelScheduledValues(time);
voice.vca.gain.exponentialRampToValueAtTime(0.001, time + 0.02);
});
this.activeGains.clear();
this.activeVoices.clear();
}
}
28 changes: 16 additions & 12 deletions src/logic/drums/TR808HiHat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,13 @@ 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();
private activeVoices: Set<{ oscillators: Tone.Oscillator[], envGain: 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 @@ -28,54 +30,56 @@ export class TR808HiHat {
});

// 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
// Filter settings
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)
// Decay
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, 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
// Disposal
oscillators[0].onstop = () => {
oscillators.forEach(o => o.dispose());
mixGain.dispose();
bpf1.dispose();
bpf2.dispose();
envGain.dispose();
hpf.dispose();
this.activeGains.delete(envGain);
this.activeVoices.delete(voice);
};
}

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);
});
voice.envGain.gain.cancelScheduledValues(time);
voice.envGain.gain.exponentialRampToValueAtTime(0.001, time + 0.02);
});
this.activeGains.clear();
this.activeVoices.clear();
}
}
25 changes: 16 additions & 9 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 @@ -18,23 +20,28 @@ export class TR808Kick {
osc.connect(masterGain);
masterGain.connect(this.destination);

// Micro-randomization using shared utilities
// Micro-randomization
const tuneDrift = applyPitchDrift(tune, 1.0);
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;
// Diode Damping Emulation:
// 1. Two-stage frequency sweep
// 2. Two-stage amplitude envelope (fast 20ms initial decay to 50% volume)

const startFreq = tuneDrift * 2.5;
osc.frequency.setValueAtTime(startFreq, time);
osc.frequency.exponentialRampToValueAtTime(endFreq, time + 0.05);
osc.frequency.exponentialRampToValueAtTime(tuneDrift * 1.1, time + 0.02);
osc.frequency.exponentialRampToValueAtTime(tuneDrift, time + 0.05);

// Ensure finalDecay is at least 0.021s to allow for damping stage
const safeFinalDecay = Math.max(finalDecay, 0.021);

// VCA Amp Envelope: Instant attack, adjustable exponential decay
masterGain.gain.setValueAtTime(velocity, time);
masterGain.gain.exponentialRampToValueAtTime(0.001, time + finalDecay);
// Damping stage: drop to 50% quickly
masterGain.gain.exponentialRampToValueAtTime(velocity * 0.5, time + 0.02);
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
24 changes: 9 additions & 15 deletions src/logic/drums/TR808Snare.ts
Original file line number Diff line number Diff line change
@@ -1,30 +1,26 @@
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;

// Micro-randomization using shared utilities
// Micro-randomization
const vcaDecay = applyVariance(0.2, 0.02);
const snappyDecayBase = 0.25 + snappy * 0.15;
const snappyDecay = applyVariance(snappyDecayBase, 0.02);
const noiseFilterFreq = applyVariance(1800, 0.02);

// 808 Membrane modes: fixed at ~238Hz and ~476Hz according to research
// 808 Membrane modes: fixed at ~238Hz and ~476Hz
const oscLow = new Tone.Oscillator(applyPitchDrift(238, 1.0), "sine");
const oscHigh = new Tone.Oscillator(applyPitchDrift(476, 1.0), "sine");
oscLow.phase = Math.random() * 360;
Expand All @@ -38,18 +34,16 @@ export class TR808Snare {
gainLow.connect(this.destination);
gainHigh.connect(this.destination);

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

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

// Snappy Layer
const randomStart = Math.random() * (this.noiseBuffer.duration - snappyDecay - 0.1);
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 @@ -66,7 +60,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
Loading