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
35 changes: 32 additions & 3 deletions src/components/DrumsView.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,12 @@
import { useDrumStore, DrumParams } from '../store/instrumentStore'
import { Knob } from './Knob'
import { useBassStore, useHarmonyStore } from '../store/instrumentStore'
import { generateBassPattern } from '../logic/StingGenerator'
import { Dices } from 'lucide-react'
import { useAudioStore, AudioState } from '../store/audioStore'
import { bjorklund, rotateArray } from '../logic/bjorklund'
import { TransportControls } from './TransportControls'

export function DrumsView() {
const { kick, snare, hihat, hihatOpen, clap, kit, drive, setParams, setKit, setDrive } = useDrumStore()
const { kick, snare, hihat, hihatOpen, clap, kit, drive, setParams, setKit, setDrive, randomizeDrums } = useDrumStore()
const { drumMachine, volumes, setVolume } = useAudioStore()

const updateDrum = (drum: 'kick' | 'snare' | 'hihat' | 'hihatOpen' | 'clap' | 'cowbell', params: Partial<DrumParams>) => {
Expand All @@ -30,6 +28,20 @@ export function DrumsView() {
if (drumMachine) drumMachine.setSaturation(v)
}

const handleRandomize = () => {
// @ts-ignore
window.Telegram?.WebApp?.HapticFeedback?.impactOccurred('medium')
randomizeDrums()
// Sync all randomized params to the drum machine
if (drumMachine) {
const state = useDrumStore.getState()
const drums = ['kick', 'snare', 'hihat', 'hihatOpen', 'clap', 'cowbell'] as const
drums.forEach(d => {
drumMachine.setDrumParams(d, state[d].pitch, state[d].decay)
})
}
}

return (
<div style={{ display: 'flex', flexDirection: 'column', gap: '24px' }}>
<TransportControls title="Драм-машина" />
Expand All @@ -38,6 +50,23 @@ export function DrumsView() {
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<h3 style={{ margin: 0 }}>Настройки</h3>
<div style={{ display: 'flex', gap: '12px', alignItems: 'center' }}>
<button
onClick={handleRandomize}
aria-label="randomize"
style={{
background: 'rgba(0,0,0,0.05)',
border: 'none',
borderRadius: '8px',
padding: '8px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
cursor: 'pointer',
color: 'var(--tg-theme-text-color)'
}}
>
<Dices size={20} />
</button>
<Knob
label="DRIVE"
value={drive}
Expand Down
8 changes: 8 additions & 0 deletions src/logic/DrumMachine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,14 @@ export class DrumMachine {
}
}

syncInternalParams(kit: '808' | '909', drive: number, drumParams: Record<string, { pitch: number, decay: number }>) {
this.setKit(kit)
this.setSaturation(drive)
Object.entries(drumParams).forEach(([drum, p]) => {
this.setDrumParams(drum, p.pitch, p.decay)
})
}

setSaturation(amount: number) {
this.shaper.curve = makeDistortionCurve(amount)
}
Expand Down
39 changes: 39 additions & 0 deletions src/logic/DrumUtils.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import * as Tone from 'tone'

/**
* Shared DSP utilities for drum synthesis based on research specs.
*/
Expand Down Expand Up @@ -40,3 +42,40 @@ export function applyVariance(base: number, variance: number = 0.02): number {
// Math.random() * 0.04 - 0.02 gives range [-0.02, 0.02]
return base * (1 + (Math.random() * (variance * 2) - variance));
}

/**
* Generates a 2.0 second buffer of white noise.
*/
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.0 second buffer of 15-bit LFSR noise (pseudo-random sequence).
* Polynomial: x^15 + x^14 + 1
*/
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 lfsr = 0x7FFF; // Initial state (non-zero)
for (let i = 0; i < bufferSize; i++) {
// x^15 + x^14 + 1
// Taps at 15 and 14 (indices 14 and 13)
const bit = ((lfsr >> 14) ^ (lfsr >> 13)) & 1;
lfsr = ((lfsr << 1) | bit) & 0x7FFF;

// Convert to range [-1.0, 1.0]
data[i] = (lfsr / 0x3FFF) - 1.0;
}
return buffer;
}
16 changes: 9 additions & 7 deletions src/logic/drums/TR808Clap.ts
Original file line number Diff line number Diff line change
@@ -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");
Expand All @@ -20,6 +19,9 @@ export class TR808Clap {
noiseSrc.connect(bpf);
bpf.connect(gain);

// Randomize noise start
const randomStart = Math.random() * 1.5;

// Triple attack "snaps"
const snapCount = 3;
const snapIntervalBase = 0.01;
Expand All @@ -28,7 +30,7 @@ export class TR808Clap {
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.exponentialRampToValueAtTime(Math.max(0.001, velocity * 0.1), snapTime + snapInterval * 0.8);
}

// Final decay
Expand All @@ -39,7 +41,7 @@ export class TR808Clap {
gain.gain.setValueAtTime(velocity, finalDecayStart);
gain.gain.exponentialRampToValueAtTime(0.001, finalDecayStart + decayTime);

noiseSrc.start(time).stop(finalDecayStart + decayTime);
noiseSrc.start(time, randomStart).stop(finalDecayStart + decayTime);

noiseSrc.onended = () => {
noiseSrc.dispose();
Expand Down
32 changes: 19 additions & 13 deletions src/logic/drums/TR808Cowbell.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,13 @@ import * as Tone from 'tone'
import { applyPitchDrift, applyVariance } from '../DrumUtils'

export class TR808Cowbell {
private activeGains: Set<Tone.Gain> = new Set();
private activeVoices: Set<{ oscillators: Tone.Oscillator[], gain: Tone.Gain }> = new Set();

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
Expand All @@ -33,27 +35,31 @@ export class TR808Cowbell {
vca.gain.setValueAtTime(velocity, time);
vca.gain.exponentialRampToValueAtTime(0.001, time + decayTime);

this.activeGains.add(vca);
const voice = { oscillators: [osc1, osc2], gain: vca };
this.activeVoices.add(voice);

osc1.start(time).stop(time + decayTime);
osc2.start(time).stop(time + decayTime);

osc1.onstop = () => {
osc1.dispose();
osc2.dispose();
mixGain.dispose();
bpf.dispose();
hpf.dispose();
vca.dispose();
this.activeGains.delete(vca);
setTimeout(() => {
osc1.dispose();
osc2.dispose();
mixGain.dispose();
bpf.dispose();
hpf.dispose();
vca.dispose();
this.activeVoices.delete(voice);
}, 100);
};
}

stop(time: number) {
this.activeGains.forEach(vca => {
vca.gain.cancelScheduledValues(time);
vca.gain.exponentialRampToValueAtTime(0.001, time + 0.02);
this.activeVoices.forEach(voice => {
voice.gain.gain.cancelScheduledValues(time);
voice.gain.gain.exponentialRampToValueAtTime(0.001, time + 0.02);
voice.oscillators.forEach(osc => osc.stop(time + 0.02));
});
this.activeGains.clear();
this.activeVoices.clear();
}
}
36 changes: 20 additions & 16 deletions src/logic/drums/TR808HiHat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,13 @@ import { applyPitchDrift, applyVariance } from '../DrumUtils'

export class TR808HiHat {
private frequencies = [205.3, 304.4, 369.6, 522.7, 800, 540];
private activeGains: Set<Tone.Gain> = new Set();
private activeVoices: Set<{ oscillators: Tone.Oscillator[], gain: Tone.Gain }> = new Set();

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");
Expand All @@ -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);
Expand All @@ -51,31 +52,34 @@ export class TR808HiHat {
envGain.gain.setValueAtTime(velocity, time);
envGain.gain.exponentialRampToValueAtTime(0.001, time + decayTime);

this.activeGains.add(envGain);
const voice = { oscillators, gain: envGain };
this.activeVoices.add(voice);

// Scheduling
oscillators.forEach(osc => {
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();
bpf1.dispose();
bpf2.dispose();
envGain.dispose();
hpf.dispose();
this.activeGains.delete(envGain);
setTimeout(() => {
oscillators.forEach(o => o.dispose());
mixGain.dispose();
bpf1.dispose();
bpf2.dispose();
envGain.dispose();
hpf.dispose();
this.activeVoices.delete(voice);
}, 100); // Safety margin
};
}

stop(time: number) {
this.activeGains.forEach(gain => {
gain.gain.cancelScheduledValues(time);
gain.gain.exponentialRampToValueAtTime(0.001, time + 0.02);
this.activeVoices.forEach(voice => {
voice.gain.gain.cancelScheduledValues(time);
voice.gain.gain.exponentialRampToValueAtTime(0.001, time + 0.02);
voice.oscillators.forEach(osc => osc.stop(time + 0.02));
});
this.activeGains.clear();
this.activeVoices.clear();
}
}
13 changes: 9 additions & 4 deletions src/logic/drums/TR808Kick.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -21,20 +23,23 @@ export class TR808Kick {
// Micro-randomization using shared utilities
const tuneDrift = applyPitchDrift(tune, 1.0);
const finalDecay = applyVariance(decayTime, 0.02);
// Ensure final decay is always longer than the damping stage
const safeFinalDecay = Math.max(finalDecay, 0.05);

// 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
// VCA Amp Envelope: Diode damping emulation
// Two-stage: fast 20ms initial decay to 50% volume, then long tail
masterGain.gain.setValueAtTime(velocity, time);
masterGain.gain.exponentialRampToValueAtTime(0.001, time + finalDecay);
masterGain.gain.exponentialRampToValueAtTime(Math.max(0.001, velocity * 0.5), time + 0.02);
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
23 changes: 11 additions & 12 deletions src/logic/drums/TR808Snare.ts
Original file line number Diff line number Diff line change
@@ -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;

Expand All @@ -40,10 +36,10 @@ 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);
gainLow.gain.setValueAtTime(Math.max(0.001, velocity * (1 - toneBalance)), time);
gainLow.gain.exponentialRampToValueAtTime(0.001, time + vcaDecay);

gainHigh.gain.setValueAtTime(velocity * toneBalance, time);
gainHigh.gain.setValueAtTime(Math.max(0.001, velocity * toneBalance), time);
gainHigh.gain.exponentialRampToValueAtTime(0.001, time + vcaDecay * 0.75);

// Snappy Layer
Expand All @@ -61,12 +57,15 @@ export class TR808Snare {
noiseFilter.connect(snappyGain);
snappyGain.connect(this.destination);

snappyGain.gain.setValueAtTime(velocity * 0.8, time);
// Randomize noise start to avoid machine-gun effect
const randomStart = Math.random() * 1.5;

snappyGain.gain.setValueAtTime(Math.max(0.001, velocity * 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 = () => {
Expand Down
Loading