diff --git a/src/logic/DrumMachine.ts b/src/logic/DrumMachine.ts index cdf1d2e1..a36cbf65 100644 --- a/src/logic/DrumMachine.ts +++ b/src/logic/DrumMachine.ts @@ -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 diff --git a/src/logic/DrumUtils.ts b/src/logic/DrumUtils.ts index b9917c79..bb277e8b 100644 --- a/src/logic/DrumUtils.ts +++ b/src/logic/DrumUtils.ts @@ -1,3 +1,5 @@ +import * as Tone from 'tone' + /** * Shared DSP utilities for drum synthesis based on research specs. */ @@ -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); } /** @@ -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; +} diff --git a/src/logic/drums/TR808Clap.ts b/src/logic/drums/TR808Clap.ts index 3334a6fe..e8b6f939 100644 --- a/src/logic/drums/TR808Clap.ts +++ b/src/logic/drums/TR808Clap.ts @@ -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"); @@ -25,10 +24,11 @@ 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 @@ -36,10 +36,12 @@ export class TR808Clap { 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(); diff --git a/src/logic/drums/TR808Cowbell.ts b/src/logic/drums/TR808Cowbell.ts index 3dd1d4fd..95215def 100644 --- a/src/logic/drums/TR808Cowbell.ts +++ b/src/logic/drums/TR808Cowbell.ts @@ -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 @@ -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); diff --git a/src/logic/drums/TR808HiHat.ts b/src/logic/drums/TR808HiHat.ts index acd1216d..fe4046fd 100644 --- a/src/logic/drums/TR808HiHat.ts +++ b/src/logic/drums/TR808HiHat.ts @@ -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"); @@ -20,7 +22,7 @@ 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); @@ -28,7 +30,6 @@ export class TR808HiHat { }); // Routing Graph - // Oscillators -> MixGain -> [BPF1, BPF2] (Parallel) -> EnvGain -> HPF -> Destination mixGain.connect(bpf1); mixGain.connect(bpf2); bpf1.connect(envGain); @@ -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); @@ -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(); diff --git a/src/logic/drums/TR808Kick.ts b/src/logic/drums/TR808Kick.ts index 461f73fd..b1307cb5 100644 --- a/src/logic/drums/TR808Kick.ts +++ b/src/logic/drums/TR808Kick.ts @@ -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 @@ -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(); diff --git a/src/logic/drums/TR808Snare.ts b/src/logic/drums/TR808Snare.ts index 23f8f2ac..d3fafcab 100644 --- a/src/logic/drums/TR808Snare.ts +++ b/src/logic/drums/TR808Snare.ts @@ -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; @@ -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; @@ -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", @@ -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 = () => { diff --git a/src/logic/drums/TR909Kick.ts b/src/logic/drums/TR909Kick.ts index dd2582ed..39852d54 100644 --- a/src/logic/drums/TR909Kick.ts +++ b/src/logic/drums/TR909Kick.ts @@ -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; @@ -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 @@ -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); @@ -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); @@ -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 @@ -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 = () => { diff --git a/src/logic/drums/TR909Snare.ts b/src/logic/drums/TR909Snare.ts index 4841f055..7fd3ffdb 100644 --- a/src/logic/drums/TR909Snare.ts +++ b/src/logic/drums/TR909Snare.ts @@ -1,5 +1,5 @@ import * as Tone from 'tone' -import { makeDistortionCurve, applyPitchDrift, applyVariance } from '../DrumUtils' +import { makeDistortionCurve, applyPitchDrift, applyVariance, generateLFSRNoise } from '../DrumUtils' export class TR909Snare { private noiseBuffer: AudioBuffer; @@ -8,18 +8,12 @@ export class TR909Snare { constructor(private destination: Tone.ToneAudioNode) { // Soft Clipping curve from research this.bodyCurve = makeDistortionCurve(15); - - const sampleRate = Tone.getContext().sampleRate; - const bufferSize = sampleRate * 0.5; - this.noiseBuffer = Tone.getContext().createBuffer(1, bufferSize, sampleRate); - const data = this.noiseBuffer.getChannelData(0); - // While original used LFSR, research says Math.random() is sufficient for Web Audio API context - for (let i = 0; i < data.length; i++) { - data[i] = Math.random() * 2 - 1; - } + this.noiseBuffer = generateLFSRNoise(Tone.getContext()); } trigger(time: number, pitch: number, snappy: number, velocity: number = 0.8) { + if (velocity <= 0) return; + // 909 Snare Body: 2 triangle oscillators fixed at ~160Hz and ~220Hz const freq1 = 160; const freq2 = 220; @@ -29,13 +23,14 @@ export class TR909Snare { const snappyDecayBase = 0.1 + snappy * 0.4; const snappyDecay = applyVariance(snappyDecayBase, 0.02); const noiseHPFFreq = applyVariance(1000, 0.02); - const toneDrift1 = applyPitchDrift(freq1, 1.0); - const toneDrift2 = applyPitchDrift(freq2, 1.0); + const toneDrift1 = applyPitchDrift(freq1, 2.0); // 2 cents drift + const toneDrift2 = applyPitchDrift(freq2, 2.0); const osc1 = new Tone.Oscillator(toneDrift1 * 2, "triangle"); const osc2 = new Tone.Oscillator(toneDrift2 * 2, "triangle"); osc1.phase = Math.random() * 360; osc2.phase = Math.random() * 360; + // Routing with gain compensation to prevent clipping before the shaper const preShaperGain = new Tone.Gain(0.5); const bodyShaper = new Tone.WaveShaper(this.bodyCurve); @@ -60,11 +55,14 @@ export class TR909Snare { osc2.frequency.setValueAtTime(startFreq2, time); osc2.frequency.exponentialRampToValueAtTime(toneDrift2, time + sweepTime); - tonalGain.gain.setValueAtTime(velocity, time); + const v = Math.max(0.001, velocity); + tonalGain.gain.setValueAtTime(v, time); tonalGain.gain.exponentialRampToValueAtTime(0.001, time + vcaDecay); - // Snappy Layer + // Snappy Layer (LFSR Noise) const noiseSrc = new Tone.BufferSource(this.noiseBuffer); + const randomStart = Math.random() * (this.noiseBuffer.duration - snappyDecay - 0.1); + const hpf = new Tone.Filter(noiseHPFFreq, "highpass"); // HPF to protect fundamental // LPF controlled by 'Tone' (pitch parameter here), range 4kHz to 8kHz (research: toneCutoff) const toneCutoff = 4000 + pitch * 4000; @@ -76,12 +74,12 @@ export class TR909Snare { lpf.connect(noiseGain); noiseGain.connect(this.destination); - noiseGain.gain.setValueAtTime(velocity * 0.7, time); + noiseGain.gain.setValueAtTime(v * 0.7, time); noiseGain.gain.exponentialRampToValueAtTime(0.001, time + snappyDecay); osc1.start(time).stop(time + vcaDecay); osc2.start(time).stop(time + vcaDecay); - noiseSrc.start(time).stop(time + snappyDecay + 0.1); + noiseSrc.start(time, randomStart).stop(time + snappyDecay + 0.1); osc1.onstop = () => { osc1.dispose(); diff --git a/src/store/audioStore.ts b/src/store/audioStore.ts index c90d17e8..891d60a7 100644 --- a/src/store/audioStore.ts +++ b/src/store/audioStore.ts @@ -3,6 +3,7 @@ import * as Tone from 'tone' import { AcidSynth } from '../logic/AcidSynth' import { DrumMachine } from '../logic/DrumMachine' import { PadSynth } from '../logic/PadSynth' +import { useDrumStore } from './instrumentStore' export interface AudioState { isInitialized: boolean @@ -60,6 +61,17 @@ export const useAudioStore = create((set, get) => ({ const drums = new DrumMachine() const pads = new PadSynth() + // Sync initial drum parameters from instrumentStore + const drumStore = useDrumStore.getState() + drums.syncInternalParams(drumStore.kit, drumStore.drive, { + kick: drumStore.kick, + snare: drumStore.snare, + hihat: drumStore.hihat, + hihatOpen: drumStore.hihatOpen, + clap: drumStore.clap, + cowbell: drumStore.cowbell + }) + Tone.Transport.bpm.value = get().bpm Tone.Transport.swing = get().swing