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
39 changes: 37 additions & 2 deletions src/components/DrumsView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,32 @@ 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 { Dices as DiceIcon } 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 handleRandomize = () => {
if (window.Telegram?.WebApp?.HapticFeedback) {
window.Telegram.WebApp.HapticFeedback.impactOccurred('medium')
}
randomizeDrums()

// Sync internal params after randomization
if (drumMachine) {
const state = useDrumStore.getState()
drumMachine.setSaturation(state.drive)
const drums: ('kick' | 'snare' | 'hihat' | 'hihatOpen' | 'clap' | 'cowbell')[] = ['kick', 'snare', 'hihat', 'hihatOpen', 'clap', 'cowbell']
drums.forEach(d => {
drumMachine.setDrumParams(d, state[d].pitch, state[d].decay)
})
}
}

const updateDrum = (drum: 'kick' | 'snare' | 'hihat' | 'hihatOpen' | 'clap' | 'cowbell', params: Partial<DrumParams>) => {
setParams(drum, params)
if (drumMachine) {
Expand Down Expand Up @@ -38,6 +55,24 @@ 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
aria-label="randomize"
onClick={handleRandomize}
style={{
width: '40px',
height: '40px',
borderRadius: '20px',
border: 'none',
background: 'rgba(0,0,0,0.05)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: 'var(--tg-theme-button-color)',
cursor: 'pointer'
}}
>
<DiceIcon size={20} />
</button>
<Knob
label="DRIVE"
value={drive}
Expand Down
43 changes: 43 additions & 0 deletions src/logic/DrumUtils.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,50 @@
import * as Tone from 'tone'

/**
* Shared DSP utilities for drum synthesis based on research specs.
*/

/**
* Generates an AudioBuffer containing pseudo-random digital noise
* using a 15-bit LFSR (characteristic polynomial x^15 + x^14 + 1).
*/
export function generateLFSRNoise(context: Tone.BaseContext, duration: number = 0.5): AudioBuffer {
const sampleRate = context.sampleRate;
const bufferSize = Math.floor(sampleRate * duration);
const buffer = context.createBuffer(1, bufferSize, sampleRate);
const data = buffer.getChannelData(0);

let lfsr = 0x7FFF; // Initial non-zero state

for (let i = 0; i < bufferSize; i++) {
// x^15 + x^14 + 1
// Taps at bit 15 and 14 (1-indexed) -> 14 and 13 (0-indexed)
const bit = ((lfsr >> 14) ^ (lfsr >> 13)) & 1;
lfsr = ((lfsr << 1) | bit) & 0x7FFF;

// Convert bit to signal [-1, 1]
data[i] = (lfsr & 1) ? 1 : -1;
}

return buffer;
}

/**
* Generates an AudioBuffer containing white noise.
*/
export function generateWhiteNoise(context: Tone.BaseContext, duration: number = 2.0): AudioBuffer {
const sampleRate = context.sampleRate;
const bufferSize = Math.floor(sampleRate * duration);
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;
}

/**
* Creates a soft-clipping saturation curve (hyperbolic tangent approximation).
* Formula: (3 + k) * x * 20 * deg / (Math.PI + k * Math.abs(x))
Expand Down
10 changes: 4 additions & 6 deletions src/logic/drums/TR808Clap.ts
Original file line number Diff line number Diff line change
@@ -1,18 +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(), 2.0);
}

trigger(time: number, pitch: number, decay: number, velocity: number = 0.8) {
const noiseSrc = new Tone.BufferSource(this.noiseBuffer);
const randomStart = Math.random() * (this.noiseBuffer.duration * 0.5);
const bpfFreq = (1000 + pitch * 1000);
const bpf = new Tone.Filter(applyVariance(bpfFreq, 0.02), "bandpass");
const gain = new Tone.Gain(0).connect(this.destination);
Expand All @@ -39,7 +37,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
13 changes: 10 additions & 3 deletions src/logic/drums/TR808Kick.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,11 +30,18 @@ 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: Instant attack, two-stage decay emulating diode damping
// Stage 1: Fast initial damping (diode effect) - 20ms to 50%
const dampingTime = 0.02;
const dampingLevel = Math.max(0.001, velocity * 0.5);
masterGain.gain.setValueAtTime(velocity, time);
masterGain.gain.exponentialRampToValueAtTime(0.001, time + finalDecay);
masterGain.gain.exponentialRampToValueAtTime(dampingLevel, time + dampingTime);

osc.start(time).stop(time + finalDecay);
// Stage 2: Natural exponential decay
const safeFinalDecay = Math.max(dampingTime + 0.01, finalDecay);
masterGain.gain.exponentialRampToValueAtTime(0.001, time + safeFinalDecay);

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

osc.onstop = () => {
osc.dispose();
Expand Down
14 changes: 5 additions & 9 deletions src/logic/drums/TR808Snare.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,11 @@
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(), 2.0);
}

trigger(time: number, pitch: number, snappy: number, velocity: number = 0.8) {
Expand Down Expand Up @@ -48,6 +42,8 @@ export class TR808Snare {

// Snappy Layer
const noiseSrc = new Tone.BufferSource(this.noiseBuffer);
const randomStart = Math.random() * (this.noiseBuffer.duration * 0.5);

// High-pass filter (>1800Hz) to prevent phase trap with tonal body
// Q = 0.707 (Butterworth)
const noiseFilter = new Tone.Filter({
Expand All @@ -66,7 +62,7 @@ export class TR808Snare {

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
8 changes: 5 additions & 3 deletions src/logic/drums/TR909Kick.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,8 @@ export class TR909Kick {
private bodyCurve: Float32Array;

constructor(private destination: Tone.ToneAudioNode) {
// Soft Clipping curve from research
this.bodyCurve = makeDistortionCurve(10);
// Soft Clipping curve from research (intensity 30 for aggressive 909)
this.bodyCurve = makeDistortionCurve(30);

const sampleRate = Tone.getContext().sampleRate;
const bufferSize = sampleRate * 0.05; // 50ms click
Expand Down Expand Up @@ -58,6 +58,8 @@ export class TR909Kick {

// Click Layer (Noise)
const noiseSrc = new Tone.BufferSource(this.noiseBuffer);
const randomStart = Math.random() * (this.noiseBuffer.duration * 0.5);

const noiseFilter = new Tone.Filter(noiseFilterFreq, "highpass"); // HPF > 1kHz to avoid phase trap
const noiseGain = new Tone.Gain(0);

Expand All @@ -80,7 +82,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
17 changes: 7 additions & 10 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 for 909 snappy character
this.noiseBuffer = generateLFSRNoise(Tone.getContext());
}

trigger(time: number, pitch: number, snappy: number, velocity: number = 0.8) {
Expand Down Expand Up @@ -65,6 +59,9 @@ export class TR909Snare {

// Snappy Layer
const noiseSrc = new Tone.BufferSource(this.noiseBuffer);
// Start from random offset to avoid "machine-gun" effect
const randomStart = Math.random() * (this.noiseBuffer.duration * 0.5);

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 Avoid truncating 909 snare noise tails

With the 909 snare using the default 0.5s LFSR buffer, this random offset leaves only 0.25–0.5s of source audio before the non-looping BufferSource reaches EOF. The snappy layer is scheduled for about 0.4s at the default snappy value and up to about 0.6s at high snappy, so offsets above ~0.1s cut the noise off before the envelope finishes, making otherwise identical snare hits randomly lose their tail. Constrain the offset to duration - scheduledLength, loop the buffer, or generate a longer LFSR buffer.

Useful? React with 👍 / 👎.


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 @@ -81,7 +78,7 @@ export class TR909Snare {

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
21 changes: 18 additions & 3 deletions src/store/instrumentStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,22 +41,37 @@ interface DrumState {
setParams: (drum: 'kick' | 'snare' | 'hihat' | 'hihatOpen' | 'clap' | 'cowbell', params: Partial<DrumParams>) => void
setKit: (kit: '808' | '909') => void
setDrive: (drive: number) => void
randomizeDrums: () => void
}

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 },
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) => ({
[drum]: { ...state[drum], ...params }
})),
setKit: (kit) => set({ kit }),
setDrive: (drive) => set({ drive })
setDrive: (drive) => set({ drive }),
randomizeDrums: () => set((state) => {
const rand = (min: number, max: number) => Math.floor(Math.random() * (max - min + 1)) + min;
const randF = (min: number, max: number) => Math.random() * (max - min) + min;

return {
kick: { ...state.kick, pulses: rand(2, 6), pitch: randF(0.3, 0.7), decay: randF(0.4, 0.8) },
snare: { ...state.snare, pulses: rand(2, 8), rotate: rand(0, 15), pitch: randF(0.2, 0.8), decay: randF(0.3, 0.7) },
hihat: { ...state.hihat, pulses: rand(8, 14), pitch: randF(0.4, 0.9) },
hihatOpen: { ...state.hihatOpen, pulses: rand(2, 6), rotate: rand(0, 15), decay: randF(0.4, 0.7) },
clap: { ...state.clap, pulses: rand(0, 4), rotate: rand(0, 15), probability: randF(0.5, 1.0) },
cowbell: { ...state.cowbell, pulses: rand(0, 6), rotate: rand(0, 15), probability: randF(0.3, 0.9) },
drive: rand(10, 40)
}
})
}))

// Pad Store
Expand Down