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
121 changes: 78 additions & 43 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,22 @@ export function DrumsView() {
if (drumMachine) drumMachine.setSaturation(v)
}

const handleRandomize = () => {
if (window.Telegram?.WebApp?.HapticFeedback) {
window.Telegram.WebApp.HapticFeedback.impactOccurred('medium')
}
randomizeDrums();

// Ensure engine is synced immediately
if (drumMachine) {
const drums: ('kick' | 'snare' | 'hihat' | 'hihatOpen' | 'clap' | 'cowbell')[] = ['kick', 'snare', 'hihat', 'hihatOpen', 'clap', 'cowbell'];
drums.forEach(id => {
const d = useDrumStore.getState()[id];
drumMachine.setDrumParams(id, d.pitch, d.decay);
});
}
}

return (
<div style={{ display: 'flex', flexDirection: 'column', gap: '24px' }}>
<TransportControls title="Драм-машина" />
Expand All @@ -38,6 +52,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
onClick={handleRandomize}
style={{
background: 'var(--tg-theme-button-color)',
color: 'white',
border: 'none',
borderRadius: '8px',
width: '36px',
height: '36px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
cursor: 'pointer'
}}
aria-label="randomize"
>
<Dices size={20} />
</button>
<Knob
label="DRIVE"
value={drive}
Expand Down Expand Up @@ -72,46 +104,49 @@ export function DrumsView() {
{ id: 'hihatOpen' as const, label: 'OPEN HAT' },
{ id: 'clap' as const, label: 'CLAP' },
{ id: 'cowbell' as const, label: 'COWBELL' }
].map(d => (
<div key={d.id} style={{ display: 'flex', flexWrap: 'wrap', gap: '8px', alignItems: 'center', background: 'rgba(0,0,0,0.02)', padding: '12px', borderRadius: '12px' }}>
<div style={{ width: '60px', fontWeight: 'bold', fontSize: '10px' }}>{d.label}</div>
<Knob
label="Пульс"
value={useDrumStore((state) => state[d.id].pulses)}
min={0} max={16} step={1}
onChange={(v) => updateDrum(d.id, { pulses: v })}
size={40}
/>
<Knob
label="Tone"
value={useDrumStore((state) => state[d.id].pitch)}
min={0} max={1} step={0.01}
onChange={(v) => updateDrum(d.id, { pitch: v })}
size={40}
/>
<Knob
label={d.id === 'snare' ? 'Snappy' : 'Decay'}
value={useDrumStore((state) => state[d.id].decay)}
min={0} max={1} step={0.01}
onChange={(v) => updateDrum(d.id, { decay: v })}
size={40}
/>
<Knob
label="PROB"
value={useDrumStore((state) => state[d.id].probability)}
min={0} max={1} step={0.01}
onChange={(v) => updateDrum(d.id, { probability: v })}
size={40}
/>
<Knob
label="Vol"
value={volumes[d.id === 'cowbell' ? 'cow' : d.id]}
min={0} max={1} step={0.01}
onChange={(v) => setVolume(d.id === 'cowbell' ? 'cow' : d.id, v)}
size={40}
/>
</div>
))}
].map(d => {
const currentParams = useDrumStore((state) => state[d.id]);
return (
<div key={d.id} style={{ display: 'flex', flexWrap: 'wrap', gap: '8px', alignItems: 'center', background: 'rgba(0,0,0,0.02)', padding: '12px', borderRadius: '12px' }}>
<div style={{ width: '60px', fontWeight: 'bold', fontSize: '10px' }}>{d.label}</div>
<Knob
label="Пульс"
value={currentParams.pulses}
min={0} max={16} step={1}
onChange={(v) => updateDrum(d.id, { pulses: v })}
size={40}
/>
<Knob
label="Tone"
value={currentParams.pitch}
min={0} max={1} step={0.01}
onChange={(v) => updateDrum(d.id, { pitch: v })}
size={40}
/>
<Knob
label={d.id === 'snare' ? 'Snappy' : 'Decay'}
value={currentParams.decay}
min={0} max={1} step={0.01}
onChange={(v) => updateDrum(d.id, { decay: v })}
size={40}
/>
<Knob
label="PROB"
value={currentParams.probability}
min={0} max={1} step={0.01}
onChange={(v) => updateDrum(d.id, { probability: v })}
size={40}
/>
<Knob
label="Vol"
value={volumes[d.id === 'cowbell' ? 'cow' : d.id]}
min={0} max={1} step={0.01}
onChange={(v) => setVolume(d.id === 'cowbell' ? 'cow' : d.id, v)}
size={40}
/>
</div>
);
})}
</div>

{/* Pattern Visualizer */}
Expand Down
54 changes: 49 additions & 5 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 All @@ -21,13 +23,15 @@ 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.
* Formula: f_new = f_base * Math.pow(2, cents / 1200)
* @param base - Base frequency in Hz
* @param range - Drift range in Hz (default 1.0)
* @param centsRange - Drift range in cents (default 1.5)
*/
export function applyPitchDrift(base: number, range: number = 1.0): number {
return base + (Math.random() * 2 - 1) * range;
export function applyPitchDrift(base: number, centsRange: number = 1.5): number {
const cents = (Math.random() * 2 - 1) * centsRange;
return base * Math.pow(2, cents / 1200);
}

/**
Expand All @@ -40,3 +44,43 @@ 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 an authentic 15-bit LFSR pseudo-random noise buffer.
* emulates the TR-909 digital noise generator.
* Polynomial: x^15 + x^14 + 1
*/
export function generateLFSRNoise(context: Tone.BaseContext, duration: number = 2.0): AudioBuffer {
const sampleRate = context.sampleRate;
const bufferSize = sampleRate * duration;
const buffer = context.createBuffer(1, bufferSize, sampleRate);
const data = buffer.getChannelData(0);

let reg = 0x7FFF; // 15-bit register, all ones initial state

for (let i = 0; i < bufferSize; i++) {
// 909 LFSR typically runs much faster than sample rate,
// but here we generate per sample for the buffer.
const bit = ((reg >> 0) ^ (reg >> 1)) & 1;
reg = (reg >> 1) | (bit << 14);
data[i] = (bit * 2 - 1) * 0.5; // Scale to [-0.5, 0.5]
}

return buffer;
}

/**
* Generates a standard white noise buffer.
*/
export function generateWhiteNoise(context: Tone.BaseContext, duration: number = 2.0): AudioBuffer {
const sampleRate = context.sampleRate;
const bufferSize = 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;
}
13 changes: 7 additions & 6 deletions src/logic/drums/TR808Clap.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,19 @@
import * as Tone from 'tone'
import { applyVariance } from '../DrumUtils'
import { applyPitchDrift, 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) {
if (velocity <= 0) return;

const noiseSrc = new Tone.BufferSource(this.noiseBuffer);
const randomStart = Math.random() * (this.noiseBuffer.duration - 1.0);

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 +40,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
20 changes: 13 additions & 7 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 @@ -15,6 +17,8 @@ export class TR808Cowbell {

const osc1 = new Tone.Oscillator(freq1, "square");
const osc2 = new Tone.Oscillator(freq2, "square");
osc1.phase = Math.random() * 360;
osc2.phase = Math.random() * 360;

const mixGain = new Tone.Gain(0.5);
const bpf = new Tone.Filter(applyVariance(800, 0.02), "bandpass");
Expand All @@ -33,7 +37,8 @@ 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);
Expand All @@ -45,15 +50,16 @@ export class TR808Cowbell {
bpf.dispose();
hpf.dispose();
vca.dispose();
this.activeGains.delete(vca);
this.activeVoices.delete(voice);
};
}

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();
}
}
29 changes: 18 additions & 11 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 @@ -20,15 +22,14 @@ export class TR808HiHat {

// Create 6 Square Wave Oscillators (Schmitt Trigger Matrix)
const oscillators = this.frequencies.map(freq => {
const driftedFreq = applyPitchDrift(freq * pitchMultiplier, 2.0); // +/- 2Hz drift for hats
const driftedFreq = applyPitchDrift(freq * pitchMultiplier, 2.0);
const osc = new Tone.Oscillator(driftedFreq, "square");
osc.phase = Math.random() * 360;
osc.connect(mixGain);
return osc;
});

// Routing Graph
// Oscillators -> MixGain -> [BPF1, BPF2] (Parallel) -> EnvGain -> HPF -> Destination
mixGain.connect(bpf1);
mixGain.connect(bpf2);
bpf1.connect(envGain);
Expand All @@ -51,31 +52,37 @@ 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);
this.activeVoices.delete(voice);
};
}

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);
// Oscillators will stop naturally because their stop(time) was already scheduled,
// but we ensure the gain cuts out.
// In a more robust system, we'd also call osc.stop(time + 0.02) here.
voice.oscillators.forEach(osc => {
osc.stop(time + 0.02);
});
});
this.activeGains.clear();
this.activeVoices.clear();
}
}
Loading