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
44 changes: 38 additions & 6 deletions src/logic/DrumUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,13 +21,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.
* Typically +/- 1-2 cents as per research.
* @param base - Base frequency in Hz
* @param range - Drift range in Hz (default 1.0)
* @param centsRange - Drift range in cents (default 2.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 = 2.0): number {
const cents = (Math.random() * 2 - 1) * centsRange;
return base * Math.pow(2, cents / 1200);
Comment on lines +29 to +31

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 Preserve hertz-based callers of applyPitchDrift

This helper still has callers outside this diff that pass a drift range in Hz, for example TR808HiHat calls applyPitchDrift(..., 2.0) with a +/- 2Hz comment and TR808Cowbell passes the same value. After changing the second parameter to cents, those unchanged instruments now get only ±2 cents of drift instead of the intended hertz detune, so hats/cowbell lose much of their analog variation whenever they trigger. Please either keep a Hz-based helper for existing call sites or update every caller to the new unit.

Useful? React with 👍 / 👎.

}

/**
Expand All @@ -37,6 +38,37 @@ 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 an authentic LFSR (Linear Feedback Shift Register) noise buffer.
* Emulates the 15-bit digital noise used in TR-909.
* Polynomial: x^15 + x^14 + 1
* @param context - AudioContext to create the buffer in
* @param duration - Duration in seconds
*/
export function generateLFSRNoise(context: any, duration: number = 0.5): AudioBuffer {
const sampleRate = context.sampleRate;
const bufferSize = sampleRate * duration;
const buffer = context.createBuffer(1, bufferSize, sampleRate);
const data = buffer.getChannelData(0);

let state = 0x7FFF; // 15-bit initial state (all ones)

for (let i = 0; i < bufferSize; i++) {
// Feedback bit = bit 14 XOR bit 13 (0-indexed bits of a 15-bit register)
const bit14 = (state >> 14) & 1;
const bit13 = (state >> 13) & 1;
const feedback = bit14 ^ bit13;

state = ((state << 1) | feedback) & 0x7FFF;

// Convert to [-1, 1] range
// We use bit 0 as the output bit
data[i] = ((state & 1) * 2) - 1;
}

return buffer;
}
14 changes: 10 additions & 4 deletions src/logic/drums/TR808Kick.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ 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
const finalDecay = applyVariance(decayTime, 0.02);

// Pitch Envelope: Start high (Tune * 2.5) and drop quickly (50ms) to simulate the membrane hit ('tonk')
Expand All @@ -30,11 +30,17 @@ export class TR808Kick {
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 to simulate diode damping
// 1. Initial fast decay to 50% over 20ms
// 2. Long exponential decay to zero
masterGain.gain.setValueAtTime(velocity, time);
masterGain.gain.exponentialRampToValueAtTime(0.001, time + finalDecay);
masterGain.gain.exponentialRampToValueAtTime(velocity * 0.5, time + 0.02);

osc.start(time).stop(time + finalDecay);
// Ensure finalDecay is at least 0.021s to avoid scheduling errors
const safeFinalDecay = Math.max(finalDecay, 0.021);
masterGain.gain.exponentialRampToValueAtTime(0.001, time + safeFinalDecay);

osc.start(time).stop(time + safeFinalDecay);

osc.onstop = () => {
osc.dispose();
Expand Down
4 changes: 2 additions & 2 deletions src/logic/drums/TR808Snare.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,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"); // +/- 2 cents
const oscHigh = new Tone.Oscillator(applyPitchDrift(476, 2.0), "sine");
oscLow.phase = Math.random() * 360;
oscHigh.phase = Math.random() * 360;

Expand Down
2 changes: 1 addition & 1 deletion src/logic/drums/TR909Kick.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,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, 2.0); // +/- 2 cents
const vcaDecay = applyVariance(decayTime, 0.02);
const noiseFilterFreq = applyVariance(1000, 0.02);

Expand Down
16 changes: 5 additions & 11 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 @@ -9,14 +9,8 @@ export class TR909Snare {
// 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;
}
// Use authentic LFSR noise as per research for 909 Snare
this.noiseBuffer = generateLFSRNoise(Tone.getContext(), 0.5);
}

trigger(time: number, pitch: number, snappy: number, velocity: number = 0.8) {
Expand All @@ -29,8 +23,8 @@ 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
const toneDrift2 = applyPitchDrift(freq2, 2.0);

const osc1 = new Tone.Oscillator(toneDrift1 * 2, "triangle");
const osc2 = new Tone.Oscillator(toneDrift2 * 2, "triangle");
Expand Down
13 changes: 7 additions & 6 deletions src/store/instrumentStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,12 +44,13 @@ 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 },
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 },
// Optimized Techno Foundation Patterns
kick: { steps: 16, pulses: 4, rotate: 0, decay: 0.5, pitch: 0.5, probability: 1.0 }, // 4-on-the-floor
snare: { steps: 16, pulses: 4, rotate: 4, decay: 0.5, pitch: 0.5, probability: 1.0 }, // Standard backbeat

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 Keep the default snare on the backbeat

Because SequencerLoop builds the pattern with bjorklund(steps, pulses) and then applies rotate, this 16/4 snare default produces hits at steps 0, 4, 8, and 12; rotating by 4 leaves the four-on-the-floor spacing unchanged. New sessions and MIDI exports therefore place snares on beats 1 and 3 in addition to beats 2 and 4, despite the Standard backbeat comment and the previous 16/2 default yielding only steps 4 and 12.

Useful? React with 👍 / 👎.

hihat: { steps: 16, pulses: 12, rotate: 0, decay: 0.5, pitch: 0.5, probability: 1.0 }, // 16th notes / shuffle
hihatOpen: { steps: 16, pulses: 4, rotate: 2, decay: 0.5, pitch: 0.5, probability: 1.0 },// Off-beat hats
clap: { steps: 16, pulses: 2, rotate: 4, decay: 0.5, pitch: 0.5, probability: 1.0 }, // Occasional clap
cowbell: { steps: 16, pulses: 3, rotate: 2, decay: 0.5, pitch: 0.5, probability: 0.8 }, // Syncopated cowbell
kit: '909',
drive: 20,
setParams: (drum, params) => set((state) => ({
Expand Down