diff --git a/src/components/SequencerLoop.tsx b/src/components/SequencerLoop.tsx index cc20b866..7ffb8719 100644 --- a/src/components/SequencerLoop.tsx +++ b/src/components/SequencerLoop.tsx @@ -75,6 +75,11 @@ export function SequencerLoop() { if (Math.random() < prob) { const velocity = 0.7 + Math.random() * 0.3 drumMachine.triggerDrum(id, time, velocity) + + // Haptic feedback on downbeat (Step 0) for immersion + if (step === 0 && window.Telegram?.WebApp?.HapticFeedback) { + window.Telegram.WebApp.HapticFeedback.impactOccurred('light') + } } } } diff --git a/src/logic/DrumUtils.ts b/src/logic/DrumUtils.ts index b9917c79..7bbe48b5 100644 --- a/src/logic/DrumUtils.ts +++ b/src/logic/DrumUtils.ts @@ -1,7 +1,46 @@ +import * as Tone from 'tone' + /** * Shared DSP utilities for drum synthesis based on research specs. */ +/** + * Generates a 2.0s buffer of white noise. + * @param context - Tone.BaseContext + */ +export function generateWhiteNoise(context: Tone.BaseContext): AudioBuffer { + const sampleRate = context.sampleRate; + const bufferSize = sampleRate * 2.0; + const buffer = context.createBuffer(1, bufferSize, sampleRate); + const data = buffer.getChannelData(0); + for (let i = 0; i < bufferSize; i++) { + data[i] = Math.random() * 2 - 1; + } + return buffer; +} + +/** + * Generates a 2.0s buffer of LFSR (15-bit) noise for authentic vintage texture. + * Characteristic polynomial: x^15 + x^14 + 1 + * @param context - Tone.BaseContext + */ +export function generateLFSRNoise(context: Tone.BaseContext): AudioBuffer { + const sampleRate = context.sampleRate; + const bufferSize = sampleRate * 2.0; + const buffer = context.createBuffer(1, bufferSize, sampleRate); + const data = buffer.getChannelData(0); + + let reg = 0x7FFF; // Initialize with non-zero + for (let i = 0; i < bufferSize; i++) { + // x^15 + x^14 + 1 + const bit = ((reg >> 14) ^ (reg >> 13)) & 1; + reg = ((reg << 1) | bit) & 0x7FFF; + // Output -1 or 1 based on the bit + data[i] = (bit * 2) - 1; + } + return buffer; +} + /** * Creates a soft-clipping saturation curve (hyperbolic tangent approximation). * Formula: (3 + k) * x * 20 * deg / (Math.PI + k * Math.abs(x)) diff --git a/src/logic/drums/TR808Clap.ts b/src/logic/drums/TR808Clap.ts index 3334a6fe..3189d952 100644 --- a/src/logic/drums/TR808Clap.ts +++ b/src/logic/drums/TR808Clap.ts @@ -1,20 +1,21 @@ 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) { const noiseSrc = new Tone.BufferSource(this.noiseBuffer); const bpfFreq = (1000 + pitch * 1000); - const bpf = new Tone.Filter(applyVariance(bpfFreq, 0.02), "bandpass"); + const bpf = new Tone.Filter({ + frequency: applyVariance(bpfFreq, 0.02), + type: "bandpass", + Q: applyVariance(1.0, 0.1) + }); const gain = new Tone.Gain(0).connect(this.destination); noiseSrc.connect(bpf); @@ -23,23 +24,27 @@ export class TR808Clap { // Triple attack "snaps" const snapCount = 3; const snapIntervalBase = 0.01; - const snapInterval = applyVariance(snapIntervalBase, 0.02); + let lastSnapTime = time; for (let i = 0; i < snapCount; i++) { + const snapInterval = applyVariance(snapIntervalBase, 0.1); const snapTime = time + i * snapInterval; - gain.gain.setValueAtTime(velocity, snapTime); - gain.gain.exponentialRampToValueAtTime(velocity * 0.1, snapTime + snapInterval * 0.8); + const snapVelocity = applyVariance(velocity, 0.05); + gain.gain.setValueAtTime(snapVelocity, snapTime); + gain.gain.exponentialRampToValueAtTime(snapVelocity * 0.1, snapTime + snapInterval * 0.8); + lastSnapTime = snapTime + snapInterval; } // Final decay - const finalDecayStart = time + snapCount * snapInterval; + const finalDecayStart = lastSnapTime; const decayTimeBase = 0.1 + decay * 0.5; const decayTime = applyVariance(decayTimeBase, 0.02); gain.gain.setValueAtTime(velocity, finalDecayStart); gain.gain.exponentialRampToValueAtTime(0.001, finalDecayStart + decayTime); - noiseSrc.start(time).stop(finalDecayStart + decayTime); + 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..518b2df0 100644 --- a/src/logic/drums/TR808Cowbell.ts +++ b/src/logic/drums/TR808Cowbell.ts @@ -17,8 +17,8 @@ export class TR808Cowbell { const osc2 = new Tone.Oscillator(freq2, "square"); const mixGain = new Tone.Gain(0.5); - const bpf = new Tone.Filter(applyVariance(800, 0.02), "bandpass"); - const hpf = new Tone.Filter(applyVariance(500, 0.02), "highpass"); + const bpf = new Tone.Filter(applyVariance(800, 0.03), "bandpass"); + const hpf = new Tone.Filter(applyVariance(500, 0.03), "highpass"); const vca = new Tone.Gain(0); osc1.connect(mixGain); diff --git a/src/logic/drums/TR808HiHat.ts b/src/logic/drums/TR808HiHat.ts index acd1216d..9c61911c 100644 --- a/src/logic/drums/TR808HiHat.ts +++ b/src/logic/drums/TR808HiHat.ts @@ -37,11 +37,11 @@ export class TR808HiHat { hpf.connect(this.destination); // Filter Q values and randomization - bpf1.Q.value = 1.5; - bpf2.Q.value = 1.5; + bpf1.Q.value = applyVariance(1.5, 0.1); + bpf2.Q.value = applyVariance(1.5, 0.1); bpf1.frequency.value = applyVariance(3440, 0.02); bpf2.frequency.value = applyVariance(7100, 0.02); - hpf.frequency.value = applyVariance(7000, 0.02); + hpf.frequency.value = applyVariance(7000, 0.03); // Decay: Closed Hat (40-60ms), Open Hat (300-500ms) const decayBase = isOpen ? (0.3 + decay * 0.2) : (0.04 + decay * 0.02); diff --git a/src/logic/drums/TR808Kick.ts b/src/logic/drums/TR808Kick.ts index 461f73fd..1d3b10de 100644 --- a/src/logic/drums/TR808Kick.ts +++ b/src/logic/drums/TR808Kick.ts @@ -24,17 +24,24 @@ export class TR808Kick { // 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; + // Micro-randomization of start frequency as per research + const startFreq = applyVariance(tuneDrift * 2.5, 0.03); const endFreq = tuneDrift; osc.frequency.setValueAtTime(startFreq, time); osc.frequency.exponentialRampToValueAtTime(endFreq, time + 0.05); - // VCA Amp Envelope: Instant attack, adjustable exponential decay + // VCA Amp Envelope: Two-stage decay emulating diode damping + // Stage 1: Fast 20ms decay to 50% volume + // Stage 2: Main exponential decay + const dampingTime = 0.02; + const safeFinalDecay = Math.max(dampingTime + 0.01, finalDecay); + masterGain.gain.setValueAtTime(velocity, time); - masterGain.gain.exponentialRampToValueAtTime(0.001, time + finalDecay); + masterGain.gain.exponentialRampToValueAtTime(Math.max(0.001, velocity * 0.5), time + dampingTime); + 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..f49c1e0f 100644 --- a/src/logic/drums/TR808Snare.ts +++ b/src/logic/drums/TR808Snare.ts @@ -1,22 +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) { // pitch maps to tone balance here (balance between low and high modes) - const toneBalance = pitch; + const toneBalance = applyVariance(pitch, 0.05); // Micro-randomization using shared utilities const vcaDecay = applyVariance(0.2, 0.02); @@ -48,6 +42,9 @@ export class TR808Snare { // 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.2); + // High-pass filter (>1800Hz) to prevent phase trap with tonal body // Q = 0.707 (Butterworth) const noiseFilter = new Tone.Filter({ @@ -61,12 +58,13 @@ export class TR808Snare { noiseFilter.connect(snappyGain); snappyGain.connect(this.destination); - snappyGain.gain.setValueAtTime(velocity * 0.8, time); + const snappyVelocity = applyVariance(velocity * 0.8, 0.05); + snappyGain.gain.setValueAtTime(snappyVelocity, 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..1d88f106 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; @@ -7,15 +7,8 @@ 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.bodyCurve = makeDistortionCurve(30); + this.noiseBuffer = generateWhiteNoise(Tone.getContext()); } trigger(time: number, pitch: number, decay: number, velocity: number = 0.8) { @@ -37,7 +30,7 @@ export class TR909Kick { bodyOsc.phase = Math.random() * 360; const bodyShaper = new Tone.WaveShaper(this.bodyCurve); bodyShaper.oversample = '4x'; - const bodyFilter = new Tone.Filter(1000, "lowpass"); + const bodyFilter = new Tone.Filter(applyVariance(1000, 0.03), "lowpass"); const bodyGain = new Tone.Gain(0); bodyOsc.connect(bodyShaper); @@ -58,6 +51,9 @@ export class TR909Kick { // Click Layer (Noise) const noiseSrc = new Tone.BufferSource(this.noiseBuffer); + // Random start offset to avoid "machine-gun" effect + 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 +63,8 @@ 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); + const clickVelocity = applyVariance(velocity * 0.7, 0.05); + noiseGain.gain.setValueAtTime(clickVelocity, time); noiseGain.gain.exponentialRampToValueAtTime(0.001, time + clickDecay); // Rectangular Pulse Click: Short 5ms impulse for attack articulation @@ -80,7 +77,7 @@ export class TR909Kick { 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..fa5c80b5 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,15 +8,8 @@ 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; - } + // Using authentic 15-bit LFSR noise as per research + this.noiseBuffer = generateLFSRNoise(Tone.getContext()); } trigger(time: number, pitch: number, snappy: number, velocity: number = 0.8) { @@ -51,7 +44,7 @@ export class TR909Snare { tonalGain.connect(this.destination); // Pitch Sweep: ~320Hz to ~160Hz over 30ms (as per research spec) - const sweepTime = 0.03; + const sweepTime = applyVariance(0.03, 0.1); const startFreq1 = toneDrift1 * 2; const startFreq2 = toneDrift2 * 2; @@ -65,6 +58,9 @@ export class TR909Snare { // 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); + 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 +72,13 @@ export class TR909Snare { lpf.connect(noiseGain); noiseGain.connect(this.destination); - noiseGain.gain.setValueAtTime(velocity * 0.7, time); + const snappyVelocity = applyVariance(velocity * 0.7, 0.05); + noiseGain.gain.setValueAtTime(snappyVelocity, 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/instrumentStore.ts b/src/store/instrumentStore.ts index 9e96a9b2..b5e9b3b4 100644 --- a/src/store/instrumentStore.ts +++ b/src/store/instrumentStore.ts @@ -45,11 +45,11 @@ interface DrumState { export const useDrumStore = create((set) => ({ kick: { steps: 16, pulses: 4, rotate: 0, decay: 0.5, pitch: 0.5, probability: 1.0 }, - snare: { steps: 16, pulses: 2, rotate: 4, decay: 0.5, pitch: 0.5, probability: 1.0 }, + snare: { steps: 16, pulses: 4, rotate: 4, decay: 0.5, pitch: 0.5, probability: 1.0 }, hihat: { steps: 16, pulses: 12, rotate: 0, decay: 0.5, pitch: 0.5, probability: 1.0 }, hihatOpen: { steps: 16, pulses: 4, rotate: 2, decay: 0.5, pitch: 0.5, probability: 1.0 }, - clap: { steps: 16, pulses: 2, rotate: 4, decay: 0.5, pitch: 0.5, probability: 1.0 }, - cowbell: { steps: 16, pulses: 2, rotate: 2, decay: 0.5, pitch: 0.5, probability: 1.0 }, + clap: { steps: 16, pulses: 4, rotate: 4, decay: 0.5, pitch: 0.5, probability: 1.0 }, + cowbell: { steps: 16, pulses: 3, rotate: 2, decay: 0.5, pitch: 0.5, probability: 0.8 }, kit: '909', drive: 20, setParams: (drum, params) => set((state) => ({