From 75c19850e9618f6a7eb93a7db41d9b20f659b954 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Tue, 17 Mar 2026 02:31:07 +0000 Subject: [PATCH] feat: implement research-based analog drum synthesis Updated TR-808 and TR-909 drum models (Kick, Snare, Hi-Hat) to match detailed DSP research. Key changes include: - Refined tuning and decay ranges. - Accurate pitch sweep behaviors (e.g., 2.5x for 808 Kick, 4.7x for 909 Kick). - Improved noise filtering (HPF/LPF) for Snares and Hi-Hats. - Implementation of the 6-oscillator square wave matrix for 808 Hi-Hats. - Centralized master-bus saturation using the research-provided formula. - Added micro-randomization (analog drift) for pitch and filters. Co-authored-by: Pitrat-wav <255843145+Pitrat-wav@users.noreply.github.com> --- src/logic/DrumMachine.ts | 35 +++++++++++--------------- src/logic/drums/TR808HiHat.ts | 31 ++++++++++++++++------- src/logic/drums/TR808Kick.ts | 35 +++++++------------------- src/logic/drums/TR808Snare.ts | 37 +++++++++++++++++---------- src/logic/drums/TR909Kick.ts | 42 ++++++++++++++++++++----------- src/logic/drums/TR909Snare.ts | 47 +++++++++++++++++++---------------- 6 files changed, 123 insertions(+), 104 deletions(-) diff --git a/src/logic/DrumMachine.ts b/src/logic/DrumMachine.ts index e7cef13a..4118a8fb 100644 --- a/src/logic/DrumMachine.ts +++ b/src/logic/DrumMachine.ts @@ -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 @@ -40,7 +40,10 @@ 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) @@ -48,16 +51,14 @@ export class DrumMachine { 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), @@ -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) } } @@ -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)) } @@ -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 } } diff --git a/src/logic/drums/TR808HiHat.ts b/src/logic/drums/TR808HiHat.ts index 2527d731..c299c676 100644 --- a/src/logic/drums/TR808HiHat.ts +++ b/src/logic/drums/TR808HiHat.ts @@ -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); @@ -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); @@ -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(); diff --git a/src/logic/drums/TR808Kick.ts b/src/logic/drums/TR808Kick.ts index 43a8fa4f..47c44f76 100644 --- a/src/logic/drums/TR808Kick.ts +++ b/src/logic/drums/TR808Kick.ts @@ -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"); @@ -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 @@ -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(); - }; } } diff --git a/src/logic/drums/TR808Snare.ts b/src/logic/drums/TR808Snare.ts index 3f1353be..8d4d51a8 100644 --- a/src/logic/drums/TR808Snare.ts +++ b/src/logic/drums/TR808Snare.ts @@ -6,7 +6,7 @@ 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; @@ -14,12 +14,18 @@ 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 (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); @@ -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 = () => { diff --git a/src/logic/drums/TR909Kick.ts b/src/logic/drums/TR909Kick.ts index 2d8bd87b..30e080ac 100644 --- a/src/logic/drums/TR909Kick.ts +++ b/src/logic/drums/TR909Kick.ts @@ -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); @@ -35,11 +45,12 @@ 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); @@ -47,7 +58,8 @@ export class TR909Kick { // 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); @@ -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); diff --git a/src/logic/drums/TR909Snare.ts b/src/logic/drums/TR909Snare.ts index a22374fe..a1be5569 100644 --- a/src/logic/drums/TR909Snare.ts +++ b/src/logic/drums/TR909Snare.ts @@ -6,21 +6,19 @@ export class TR909Snare { constructor(private destination: Tone.ToneAudioNode) { const sampleRate = Tone.getContext().sampleRate; const bufferSize = sampleRate * 0.5; - 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); - // Emulate 6-bit LFSR pseudo-random noise - let lfsr = 0x3F; // 6 bits + // Research says standard Math.random() is sufficient for (let i = 0; i < data.length; i++) { - const bit = ((lfsr >> 0) ^ (lfsr >> 1)) & 1; - lfsr = (lfsr >> 1) | (bit << 5); - data[i] = (bit * 2) - 1; + data[i] = Math.random() * 2 - 1; } } trigger(time: number, pitch: number, snappy: number) { // Tonal Body (2 triangle oscillators) - const freq1 = 160 + pitch * 40; - const freq2 = 220 + pitch * 50; + // Base frequencies: 160Hz and 220Hz + const freq1 = 160; + const freq2 = 220; const osc1 = new Tone.Oscillator(freq1 * 2, "triangle"); const osc2 = new Tone.Oscillator(freq2 * 2, "triangle"); @@ -30,18 +28,24 @@ export class TR909Snare { osc2.connect(tonalGain); tonalGain.connect(this.destination); - osc1.frequency.setValueAtTime(freq1 * 1.5, time); - osc1.frequency.exponentialRampToValueAtTime(freq1, time + 0.03); - osc2.frequency.setValueAtTime(freq2 * 1.5, time); - osc2.frequency.exponentialRampToValueAtTime(freq2, time + 0.03); + // Pitch sweep: 2x base frequency to base frequency over 50ms + osc1.frequency.setValueAtTime(freq1 * 2, time); + osc1.frequency.exponentialRampToValueAtTime(freq1, time + 0.05); + osc2.frequency.setValueAtTime(freq2 * 2, time); + osc2.frequency.exponentialRampToValueAtTime(freq2, time + 0.05); tonalGain.gain.setValueAtTime(1, time); - tonalGain.gain.exponentialRampToValueAtTime(0.001, time + 0.15); + tonalGain.gain.exponentialRampToValueAtTime(0.001, time + 0.2); // Tonal body decay ~200ms - // Snappy Layer (LFSR-like noise with LPF/HPF) + // Snappy Layer (Noise with LPF/HPF) const noiseSrc = new Tone.BufferSource(this.noiseBuffer); - const hpf = new Tone.Filter(1000, "highpass"); - const lpf = new Tone.Filter(4000 + pitch * 4000, "lowpass"); // pitch controls tone cutoff + const hpf = new Tone.Filter(1000, "highpass"); // HPF ~1000Hz + + // Tone control in 909 Snare: LPF between 4000Hz and 8000Hz + // We can use the 'pitch' parameter for this Tone control + const toneCutoff = 4000 + pitch * 4000; + const lpf = new Tone.Filter(toneCutoff, "lowpass"); + const noiseGain = new Tone.Gain(0); noiseSrc.connect(hpf); @@ -49,14 +53,15 @@ export class TR909Snare { lpf.connect(noiseGain); noiseGain.connect(this.destination); - const snappyDecay = 0.1 + snappy * 0.4; + // snappyDecay range + const snappyDecayTime = 0.1 + snappy * 0.4; noiseGain.gain.setValueAtTime(0.7, time); - noiseGain.gain.exponentialRampToValueAtTime(0.001, time + snappyDecay); + noiseGain.gain.exponentialRampToValueAtTime(0.001, time + snappyDecayTime); - osc1.start(time).stop(time + 0.15); - osc2.start(time).stop(time + 0.15); - noiseSrc.start(time).stop(time + snappyDecay); + osc1.start(time).stop(time + 0.2); + osc2.start(time).stop(time + 0.2); + noiseSrc.start(time).stop(time + snappyDecayTime); osc1.onstop = () => { osc1.dispose();