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
5 changes: 5 additions & 0 deletions src/components/SequencerLoop.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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')
}
}
}
}
Expand Down
39 changes: 39 additions & 0 deletions src/logic/DrumUtils.ts
Original file line number Diff line number Diff line change
@@ -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))
Expand Down
27 changes: 16 additions & 11 deletions src/logic/drums/TR808Clap.ts
Original file line number Diff line number Diff line change
@@ -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);
Expand All @@ -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();
Expand Down
4 changes: 2 additions & 2 deletions src/logic/drums/TR808Cowbell.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
6 changes: 3 additions & 3 deletions src/logic/drums/TR808HiHat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
15 changes: 11 additions & 4 deletions src/logic/drums/TR808Kick.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
20 changes: 9 additions & 11 deletions src/logic/drums/TR808Snare.ts
Original file line number Diff line number Diff line change
@@ -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);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Clamp the randomized snare tone balance

When the snare Tone control is near its maximum, applying a 5% variance can push toneBalance above 1. That value is later used in velocity * (1 - toneBalance), so those hits schedule a negative gain for the low oscillator instead of simply muting it, causing random phase inversion/extra body at the edge of the knob range. Clamp the varied value back into the expected 0–1 mix range.

Useful? React with 👍 / 👎.


// Micro-randomization using shared utilities
const vcaDecay = applyVariance(0.2, 0.02);
Expand Down Expand Up @@ -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({
Expand All @@ -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 = () => {
Expand Down
23 changes: 10 additions & 13 deletions src/logic/drums/TR909Kick.ts
Original file line number Diff line number Diff line change
@@ -1,21 +1,14 @@
import * as Tone from 'tone'
import { makeDistortionCurve, applyPitchDrift, applyVariance } from '../DrumUtils'
import { makeDistortionCurve, applyPitchDrift, applyVariance, generateWhiteNoise } from '../DrumUtils'

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

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) {
Expand All @@ -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);
Expand All @@ -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);

Expand All @@ -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
Expand All @@ -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 = () => {
Expand Down
23 changes: 10 additions & 13 deletions src/logic/drums/TR909Snare.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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) {
Expand Down Expand Up @@ -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;

Expand All @@ -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;
Expand All @@ -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();
Expand Down
6 changes: 3 additions & 3 deletions src/store/instrumentStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,11 +45,11 @@ interface DrumState {

export const useDrumStore = create<DrumState>((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) => ({
Expand Down