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
41 changes: 20 additions & 21 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,40 +40,46 @@ export class DrumMachine {

constructor() {
this.comp = new Tone.Compressor(-24, 4)
this.shaper = new Tone.WaveShaper(this.makeDistortionCurve(15))
// Research-based Soft Clipping formula in WaveShaper
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)
// Routing through Master FX chain (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),
snare: new TR808Snare(this.outputSnare),
hihat: new TR808HiHat(this.outputHihat),
hihat: new TR808HiHat(this.outputHihat, this.outputOpenHat),
clap: new TR808Clap(this.outputClap)
}

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, this.outputOpenHat), // Shared hihat synthesis
clap: new TR808Clap(this.outputClap)
}
}

/**
* Research-based Soft Clipping (Aproximation of tanh)
* Formula: (3 + k) * x * 20 * deg / (Math.PI + k * Math.abs(x))
*/
private makeDistortionCurve(amount: number) {
const k = amount
const n_samples = 44100
Expand Down Expand Up @@ -112,14 +118,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: 20 additions & 11 deletions src/logic/drums/TR808HiHat.ts
Original file line number Diff line number Diff line change
@@ -1,37 +1,46 @@
import * as Tone from 'tone'

export class TR808HiHat {
// Exact frequencies from research (HD14584 Schmitt trigger matrix)
private frequencies = [205.3, 304.4, 369.6, 522.7, 800, 540];

constructor(private destination: Tone.ToneAudioNode) { }
constructor(private destination: Tone.ToneAudioNode, private openDestination?: Tone.ToneAudioNode) { }

trigger(time: number, isOpen: boolean, pitch: number, decay: number) {
const mixGain = new Tone.Gain(0.15);
const mixGain = new Tone.Gain(0.15); // Attenuation to avoid clipping 6 squares

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 slight pitch shift based on parameter
osc.frequency.value = freq * (0.8 + pitch * 0.4) + drift;
osc.connect(mixGain);
return osc;
});

const bpf1 = new Tone.Filter(3500, "bandpass"); // 3.5kHz
// Parallel Bandpass Filters from research
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;

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

mixGain.connect(bpf1);
mixGain.connect(bpf2);
bpf1.connect(envGain);
bpf2.connect(envGain);
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);
// Use openDestination if provided and trigger is for open hat
const finalDest = (isOpen && this.openDestination) ? this.openDestination : this.destination;
hpf.connect(finalDest);

// Research: Closed Hat ~0.05s, Open Hat ~0.4s
const decayTime = isOpen ? 0.4 : 0.05;

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

// Disposal
// Disposal anchored to the first oscillator's onstop
oscillators[0].onstop = () => {
oscillators.forEach(o => o.dispose());
mixGain.dispose();
Expand Down
39 changes: 11 additions & 28 deletions src/logic/drums/TR808Kick.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,57 +4,40 @@ 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
// 808 Kick Core: Bridged-T Network emulation (Sine wave)
const osc = new Tone.Oscillator(tune, "sine");
osc.phase = Math.random() * 360; // Analog phase randomization
const masterGain = new Tone.Gain(0);

osc.connect(masterGain);
masterGain.connect(this.destination);

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

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

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

// VCA Amp Envelope: Instant attack, adjustable decay
// VCA Amp Envelope: Exponential decay
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();
};
}
}
28 changes: 17 additions & 11 deletions src/logic/drums/TR808Snare.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,12 +14,15 @@ export class TR808Snare {
}

trigger(time: number, pitch: number, snappy: number) {
// pitch maps to tone balance here (balance between low and high modes)
// pitch maps to tone balance here according to research (balance between 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 (Dual Bridged-T): ~238Hz and ~476Hz
// Micro-randomization: Pitch Drift (+/- 1-2 Hz)
const drift = (Math.random() * 2 - 1) * 1.5;
const oscLow = new Tone.Oscillator(238 + drift, "sine");
const oscHigh = new Tone.Oscillator(476 + drift, "sine");

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

// Research: Tonal decay around 0.2s
const tonalDecay = 0.2;
masterTonalGain.gain.setValueAtTime(1, time);
masterTonalGain.gain.exponentialRampToValueAtTime(0.001, time + 0.15);
masterTonalGain.gain.exponentialRampToValueAtTime(0.001, time + tonalDecay);

// Snappy Layer
// Snappy Layer (Noise)
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");
// Research: HPF ~1800Hz to avoid "Phase Trap"
const noiseFilter = new Tone.Filter(1800, "highpass");
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
// Research: Snappy decay 0.25s to 0.4s
const snappyDecay = 0.25 + snappy * 0.15;

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

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

// Cleanup
Expand Down
36 changes: 21 additions & 15 deletions src/logic/drums/TR909Kick.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,17 +14,17 @@ export class TR909Kick {
}

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");
// 909 Kick Core: Distorted Triangle wave
const bodyOsc = new Tone.Oscillator(tune, "triangle");
bodyOsc.phase = Math.random() * 360;

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

Expand All @@ -34,29 +34,35 @@ export class TR909Kick {
clipper.connect(bodyGain);
bodyGain.connect(this.destination);

// Aggressive Pitch Envelope: High start -> punch
const startFreq = 230 + (pitch * 50); // Start high for the punch
const endFreq = tune;
// Micro-randomization: Pitch Drift
const drift = (Math.random() * 2 - 1) * 1.0;

// Aggressive Pitch Envelope: Start high (~4.7x tune) -> chest punch
// Research: 200Hz - 250Hz start
const startFreq = tune * 4.7 + drift;
const endFreq = tune + drift;

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 to isolate click and avoid phase issues with body
const hpfFreq = 1000 + Math.random() * 1000; // 1kHz - 2kHz range
const noiseFilter = new Tone.Filter(hpfFreq, "highpass");
const noiseGain = new Tone.Gain(0);

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

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

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