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
35 changes: 15 additions & 20 deletions src/logic/DrumMachine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import { TR808Clap } from './drums/TR808Clap'
export class DrumMachine {
comp: Tone.Compressor
shaper: Tone.WaveShaper
output: Tone.Gain // Optional master output
output: Tone.Gain // Master output
outputKick: Tone.Gain
outputSnare: Tone.Gain
outputHihat: Tone.Gain
Expand Down Expand Up @@ -40,24 +40,25 @@ export class DrumMachine {

constructor() {
this.comp = new Tone.Compressor(-24, 4)
this.shaper = new Tone.WaveShaper(this.makeDistortionCurve(15))
// Research saturation amount/formula
this.shaper = new Tone.WaveShaper(this.makeDistortionCurve(20))
this.shaper.oversample = '4x'

this.output = new Tone.Gain(1)
this.outputKick = new Tone.Gain(1)
this.outputSnare = new Tone.Gain(1)
this.outputHihat = new Tone.Gain(1)
this.outputOpenHat = new Tone.Gain(1)
this.outputClap = new Tone.Gain(1)

this.comp.chain(this.shaper, this.output, Tone.Destination)
// Route through compression and saturation for "glue" effect
this.outputKick.connect(this.comp)
this.outputSnare.connect(this.comp)
this.outputHihat.connect(this.comp)
this.outputOpenHat.connect(this.comp)
this.outputClap.connect(this.comp)

// Let's bypass compression for individual drum channels for now,
// to simplify routing and allow strict analog synth modeling.
// We'll route them directly to destination or output
this.outputKick.connect(Tone.Destination)
this.outputSnare.connect(Tone.Destination)
this.outputHihat.connect(Tone.Destination)
this.outputOpenHat.connect(Tone.Destination)
this.outputClap.connect(Tone.Destination)
this.comp.chain(this.shaper, this.output, Tone.Destination)

this.kit808 = {
kick: new TR808Kick(this.outputKick),
Expand All @@ -69,7 +70,7 @@ export class DrumMachine {
this.kit909 = {
kick: new TR909Kick(this.outputKick),
snare: new TR909Snare(this.outputSnare),
hihat: new TR808HiHat(this.outputHihat), // Shared hihat synthesis for now
hihat: new TR808HiHat(this.outputHihat),
clap: new TR808Clap(this.outputClap)
}
}
Expand All @@ -80,6 +81,7 @@ export class DrumMachine {
const curve = new Float32Array(n_samples)
const deg = Math.PI / 180
for (let i = 0; i < n_samples; ++i) {
// Formula from research: curve[i] = (3 + k) * x * 20 * deg / (Math.PI + k * Math.abs(x))
let x = i * 2 / n_samples - 1
curve[i] = (3 + k) * x * 20 * deg / (Math.PI + k * Math.abs(x))
}
Expand Down Expand Up @@ -112,14 +114,7 @@ export class DrumMachine {
case 'kick': kit909.kick.trigger(time, p.pitch, p.decay); break
case 'snare': kit909.snare.trigger(time, p.pitch, p.decay); break
case 'hihat': kit909.hihat.trigger(time, false, p.pitch, p.decay); break
case 'hihatOpen':
// Reuse hihat logic but specify it's open
// Note: technically TR909 uses samples for open hats, but we'll use our analog emulation for now
kit909.hihat.trigger(time, true, p.pitch, p.decay);
// However, we need to route it to the right output if possible. Our TR808HiHat
// currently has one destination baked in at constructor. To mix them separately,
// we will need an architectural tweak or just use the same channel. Let's just trigger it.
break
case 'hihatOpen': kit909.hihat.trigger(time, true, p.pitch, p.decay); break
case 'clap': kit909.clap.trigger(time, p.pitch, p.decay); break
}
}
Expand Down
31 changes: 22 additions & 9 deletions src/logic/drums/TR808HiHat.ts
Original file line number Diff line number Diff line change
@@ -1,27 +1,40 @@
import * as Tone from 'tone'

export class TR808HiHat {
// Exact frequencies from research: 205.3, 304.4, 369.6, 522.7, 800, 540 Hz
private frequencies = [205.3, 304.4, 369.6, 522.7, 800, 540];

constructor(private destination: Tone.ToneAudioNode) { }

trigger(time: number, isOpen: boolean, pitch: number, decay: number) {
const mixGain = new Tone.Gain(0.15);
// Mixer for the 6 square waves
const mixGain = new Tone.Gain(0.15); // Attenuate to avoid clipping
const oscillators = this.frequencies.map(freq => {
const osc = new Tone.Oscillator(freq, "square");

// Micro-randomization: Analog Drift (+/- 2Hz)
const drift = (Math.random() - 0.5) * 4;
// Use pitch to shift all frequencies slightly
const osc = new Tone.Oscillator(freq * (0.5 + pitch) + drift, "square");
// Apply pitch multiplier (0.8x to 1.2x) while keeping hardware ratios
const pitchMultiplier = 0.8 + pitch * 0.4;
osc.frequency.value = (freq * pitchMultiplier) + drift;

osc.connect(mixGain);
return osc;
});

const bpf1 = new Tone.Filter(3500, "bandpass"); // 3.5kHz
// Parallel Bandpass Filters: 3440Hz and 7100Hz with Q=1.5
const bpf1 = new Tone.Filter(3440, "bandpass");
bpf1.Q.value = 1.5;
const bpf2 = new Tone.Filter(7000, "bandpass"); // 7kHz
const bpf2 = new Tone.Filter(7100, "bandpass");
bpf2.Q.value = 1.5;

// Micro-randomization: Filter Cutoff (+/- 2%)
bpf1.frequency.value = (bpf1.frequency.value as any) * (1 + (Math.random() * 0.04 - 0.02));
bpf2.frequency.value = (bpf2.frequency.value as any) * (1 + (Math.random() * 0.04 - 0.02));

const envGain = new Tone.Gain(0);
const hpf = new Tone.Filter(7000, "highpass"); // 7kHz high-pass cleanup
// Final HPF: 7000Hz cleanup (Sizzle)
const hpf = new Tone.Filter(7000, "highpass");

mixGain.connect(bpf1);
mixGain.connect(bpf2);
Expand All @@ -30,8 +43,8 @@ export class TR808HiHat {
envGain.connect(hpf);
hpf.connect(this.destination);

// decay: 0.5 maps to standard 808 times
const decayTime = isOpen ? (0.2 + decay * 0.8) : (0.02 + decay * 0.1);
// Decay: Closed (40-60ms), Open (300-500ms)
const decayTime = isOpen ? (0.3 + decay * 0.2) : (0.04 + decay * 0.02);

envGain.gain.setValueAtTime(1, time);
envGain.gain.exponentialRampToValueAtTime(0.001, time + decayTime);
Expand All @@ -40,7 +53,7 @@ export class TR808HiHat {
osc.start(time).stop(time + decayTime);
});

// Disposal
// Disposal anchored to first oscillator
oscillators[0].onstop = () => {
oscillators.forEach(o => o.dispose());
mixGain.dispose();
Expand Down
35 changes: 9 additions & 26 deletions src/logic/drums/TR808Kick.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,10 @@ export class TR808Kick {
constructor(private destination: Tone.ToneAudioNode) { }

trigger(time: number, pitch: number, decay: number) {
// pitch: 0.5 -> 50Hz, maps to 40-80Hz range
const tune = 40 + pitch * 40;
// decay: 0.5 -> 1.5s, maps to 0.1-3.0s range
const decayTime = 0.1 + decay * 2.9;
// pitch: 0.5 -> 52.5Hz, maps to 45-60Hz range according to research
const tune = 45 + pitch * 15;
// decay: 0.5 -> 1.7s, maps to 0.4-3.0s range according to research
const decayTime = 0.4 + decay * 2.6;

// 808 Kick Core: Bridged-T Network emulation
const osc = new Tone.Oscillator(tune, "sine");
Expand All @@ -17,11 +17,12 @@ export class TR808Kick {
osc.connect(masterGain);
masterGain.connect(this.destination);

// Micro-randomization: Pitch Drift
const drift = (Math.random() * 2 - 1) * 0.5;
// Micro-randomization: Pitch Drift (+/- 1Hz)
const drift = (Math.random() * 2 - 1);

// Pitch Envelope: Start high (~150Hz) and drop quickly to simulate the membrane hit
const startFreq = 150 + drift;
// Pitch Envelope: Start high (Tune * 2.5) and drop quickly to simulate the membrane hit
// Research: Tune * 2.5 (approx 120-150 Hz), Pitch Decay 0.04 - 0.06 sec
const startFreq = (tune * 2.5) + drift;
const endFreq = tune + drift;
const pitchDropTime = 0.05; // 50ms drop

Expand All @@ -32,29 +33,11 @@ export class TR808Kick {
masterGain.gain.setValueAtTime(1, time);
masterGain.gain.exponentialRampToValueAtTime(0.001, time + decayTime);

// Attack Click: Dirac impulse / very fast transient generator
const clickOsc = new Tone.Oscillator(startFreq * 2, "sine");
const clickGain = new Tone.Gain(0);
clickOsc.connect(clickGain);
clickGain.connect(this.destination);

clickOsc.frequency.setValueAtTime(startFreq * 2, time);
clickOsc.frequency.exponentialRampToValueAtTime(10, time + 0.02);

clickGain.gain.setValueAtTime(0.5, time);
clickGain.gain.exponentialRampToValueAtTime(0.001, time + 0.01);

osc.start(time).stop(time + decayTime);
clickOsc.start(time).stop(time + 0.02);

osc.onstop = () => {
osc.dispose();
masterGain.dispose();
};

clickOsc.onstop = () => {
clickOsc.dispose();
clickGain.dispose();
};
}
}
37 changes: 24 additions & 13 deletions src/logic/drums/TR808Snare.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,20 +6,26 @@ export class TR808Snare {
constructor(private destination: Tone.ToneAudioNode) {
const sampleRate = Tone.getContext().sampleRate;
const bufferSize = sampleRate * 0.5; // 500ms
this.noiseBuffer = Tone.getContext().createBuffer(1, bufferSize, sampleRate);
this.noiseBuffer = (Tone.getContext().rawContext as AudioContext).createBuffer(1, bufferSize, sampleRate);
const data = this.noiseBuffer.getChannelData(0);
for (let i = 0; i < data.length; i++) {
data[i] = Math.random() * 2 - 1;
}
}

trigger(time: number, pitch: number, snappy: number) {
// pitch maps to tone balance here (balance between low and high modes)
// pitch maps to tone balance (balance between low and high modes)
const toneBalance = pitch;

// 808 Membrane modes: ~180Hz and ~330Hz
const oscLow = new Tone.Oscillator(180 + (pitch * 50), "sine");
const oscHigh = new Tone.Oscillator(330 + (pitch * 80), "sine");
// 808 Membrane modes: Fixed frequencies 238Hz and 476Hz according to research
const oscLow = new Tone.Oscillator(238, "sine");
const oscHigh = new Tone.Oscillator(476, "sine");

// Micro-randomization: Pitch Drift
const drift = (Math.random() * 2 - 1);
oscLow.frequency.value = (oscLow.frequency.value as any) + drift;
oscHigh.frequency.value = (oscHigh.frequency.value as any) + drift;

const gainLow = new Tone.Gain(1 - toneBalance);
const gainHigh = new Tone.Gain(toneBalance);
const masterTonalGain = new Tone.Gain(0);
Expand All @@ -31,26 +37,31 @@ export class TR808Snare {
masterTonalGain.connect(this.destination);

masterTonalGain.gain.setValueAtTime(1, time);
masterTonalGain.gain.exponentialRampToValueAtTime(0.001, time + 0.15);
masterTonalGain.gain.exponentialRampToValueAtTime(0.001, time + 0.2); // Tonal decay ~200ms

// Snappy Layer
const noiseSrc = new Tone.BufferSource(this.noiseBuffer);
// Apply HPF (~2kHz) to noise to prevent phase cancellation with tonal oscillators
const noiseFilter = new Tone.Filter(2000, "highpass");
// HPF (~1800Hz) to noise to prevent phase cancellation
const noiseFilter = new Tone.Filter(1800, "highpass");

// Micro-randomization: Filter Cutoff (+/- 2%)
noiseFilter.frequency.value = (noiseFilter.frequency.value as any) * (1 + (Math.random() * 0.04 - 0.02));

const snappyGain = new Tone.Gain(0);

noiseSrc.connect(noiseFilter);
noiseFilter.connect(snappyGain);
snappyGain.connect(this.destination);

const snappyDecay = 0.1 + snappy * 0.4; // 0.1s to 0.5s
// snappyDecay: 0.25s to 0.4s range according to research
const snappyDecayTime = 0.25 + snappy * 0.15;

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

oscLow.start(time).stop(time + 0.15);
oscHigh.start(time).stop(time + 0.15);
noiseSrc.start(time).stop(time + snappyDecay);
oscLow.start(time).stop(time + 0.2);
oscHigh.start(time).stop(time + 0.2);
noiseSrc.start(time).stop(time + snappyDecayTime);

// Cleanup
oscLow.onstop = () => {
Expand Down
42 changes: 27 additions & 15 deletions src/logic/drums/TR909Kick.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,31 +2,41 @@ import * as Tone from 'tone'

export class TR909Kick {
private noiseBuffer: AudioBuffer;
private distortionCurve: Float32Array;

constructor(private destination: Tone.ToneAudioNode) {
const sampleRate = Tone.getContext().sampleRate;
const bufferSize = sampleRate * 0.05; // 50ms click
this.noiseBuffer = Tone.getContext().createBuffer(1, bufferSize, sampleRate);
this.noiseBuffer = (Tone.getContext().rawContext as AudioContext).createBuffer(1, bufferSize, sampleRate);
const data = this.noiseBuffer.getChannelData(0);
for (let i = 0; i < bufferSize; i++) {
data[i] = Math.random() * 2 - 1;
}

// Pre-calculate distortion curve for tanh soft clipping
this.distortionCurve = this.makeDistortionCurve(1.5);
}

private makeDistortionCurve(amount: number) {
const n_samples = 44100;
const curve = new Float32Array(n_samples);
for (let i = 0; i < n_samples; ++i) {
let x = i * 2 / n_samples - 1;
curve[i] = Math.tanh(x * amount);
}
return curve;
}

trigger(time: number, pitch: number, decay: number) {
// pitch: 0.5 -> 50Hz, maps to 45-65Hz
const tune = 45 + pitch * 20;
// decay: 0.5 -> 0.45s, maps to 0.2-0.7s
const decayTime = 0.2 + decay * 0.5;
// pitch: 0.5 -> 50Hz, maps to 45-55Hz according to research
const tune = 45 + pitch * 10;
// decay: 0.5 -> 0.45s, maps to 0.3-0.6s according to research
const decayTime = 0.3 + decay * 0.3;

// 909 Kick Core: Triangle + Soft Clipper
const bodyOsc = new Tone.Oscillator(230, "triangle");
const bodyOsc = new Tone.Oscillator(tune * 4.7, "triangle");

// Custom Soft Clipper WaveShaper
const clipper = new Tone.WaveShaper((val) => {
// Hyperbolic tangent (tanh) soft clipping
return Math.tanh(val * 1.5);
});
const clipper = new Tone.WaveShaper(this.distortionCurve);

const bodyGain = new Tone.Gain(0);

Expand All @@ -35,19 +45,21 @@ export class TR909Kick {
bodyGain.connect(this.destination);

// Aggressive Pitch Envelope: High start -> punch
const startFreq = 230 + (pitch * 50); // Start high for the punch
// Research: Start 4.7x base frequency (200-250Hz), decay 0.05 - 0.15 sec
const startFreq = tune * 4.7;
const endFreq = tune;

bodyOsc.frequency.setValueAtTime(startFreq, time);
bodyOsc.frequency.exponentialRampToValueAtTime(endFreq, time + 0.04); // Fast 40ms drop
bodyOsc.frequency.exponentialRampToValueAtTime(endFreq, time + 0.1); // 100ms drop

// VCA Envelope
bodyGain.gain.setValueAtTime(1, time);
bodyGain.gain.exponentialRampToValueAtTime(0.001, time + decayTime);

// Click Layer (Noise)
const noiseSrc = new Tone.BufferSource(this.noiseBuffer);
const noiseFilter = new Tone.Filter(1000, "highpass"); // HPF > 1kHz
// HPF around 1000-2000Hz
const noiseFilter = new Tone.Filter(1500, "highpass");
const noiseGain = new Tone.Gain(0);

noiseSrc.connect(noiseFilter);
Expand All @@ -56,7 +68,7 @@ export class TR909Kick {

// Ultra short envelope (10-20ms)
const clickDecay = 0.015;
noiseGain.gain.setValueAtTime(0.8, time);
noiseGain.gain.setValueAtTime(0.7, time);
noiseGain.gain.exponentialRampToValueAtTime(0.001, time + clickDecay);

bodyOsc.start(time).stop(time + decayTime);
Expand Down
Loading