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: 36 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,23 @@ export function DrumsView() {
if (drumMachine) drumMachine.setSaturation(v)
}

const handleRandomize = () => {
// Haptic Feedback for Telegram
if ((window as any).Telegram?.WebApp?.HapticFeedback) {
(window as any).Telegram.WebApp.HapticFeedback.impactOccurred('medium');
}
randomizeDrums();

// Sync the engine immediately
if (drumMachine) {
const drumIds: ('kick' | 'snare' | 'hihat' | 'hihatOpen' | 'clap' | 'cowbell')[] = ['kick', 'snare', 'hihat', 'hihatOpen', 'clap', 'cowbell'];
const state = useDrumStore.getState();
drumIds.forEach(id => {
drumMachine.setDrumParams(id, state[id].pitch, state[id].decay);
});
}
}

return (
<div style={{ display: 'flex', flexDirection: 'column', gap: '24px' }}>
<TransportControls title="Драм-машина" />
Expand All @@ -38,6 +53,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}
aria-label="randomize"
style={{
background: 'var(--tg-theme-button-color)',
color: 'white',
border: 'none',
borderRadius: '50%',
width: '36px',
height: '36px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
cursor: 'pointer'
}}
>
<Dices size={20} />
</button>
<Knob
label="DRIVE"
value={drive}
Expand Down
5 changes: 5 additions & 0 deletions src/components/SequencerLoop.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,11 @@ export function SequencerLoop() {
const step = stepRef.current % 16
const totalStep = stepRef.current

// Haptic Feedback for Telegram on the downbeat (step 0)
if (step === 0 && (window as any).Telegram?.WebApp?.HapticFeedback) {
(window as any).Telegram.WebApp.HapticFeedback.impactOccurred('light');
}

// Access current state directly from store to avoid loop restarts
const currentBass = useBassStore.getState()
const currentSeq = useSequencerStore.getState()
Expand Down
11 changes: 11 additions & 0 deletions src/logic/DrumMachine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,17 @@ export class DrumMachine {
this.params[drum] = { pitch, decay }
}

/**
* Bulk synchronization of kit selection, saturation, and drum parameters.
*/
syncInternalParams(kit: '808' | '909', drive: number, params: Record<string, { pitch: number, decay: number }>) {
this.setKit(kit)
this.setSaturation(drive)
Object.entries(params).forEach(([id, p]) => {
this.setDrumParams(id, p.pitch, p.decay)
})
}

triggerDrum(drum: 'kick' | 'snare' | 'hihat' | 'hihatOpen' | 'clap' | 'cowbell', time: number, velocity: number = 0.8) {
const p = this.params[drum]
const kit808 = this.kit808
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 white noise buffer.
* @param context - Tone.js Audio Context
* @param duration - Buffer duration in seconds
*/
export function generateWhiteNoise(context: Tone.BaseContext, duration: number = 2.0): AudioBuffer {
const sampleRate = context.sampleRate;
const buffer = context.createBuffer(1, sampleRate * duration, sampleRate);
const data = buffer.getChannelData(0);
for (let i = 0; i < data.length; i++) {
data[i] = Math.random() * 2 - 1;
}
return buffer;
}

/**
* Generates a 15-bit LFSR noise buffer (TR-909 style).
* Uses polynomial x^15 + x^14 + 1.
* @param context - Tone.js Audio Context
* @param duration - Buffer duration in seconds
*/
export function generateLFSRNoise(context: Tone.BaseContext, duration: number = 2.0): AudioBuffer {
const sampleRate = context.sampleRate;
const buffer = context.createBuffer(1, sampleRate * duration, sampleRate);
const data = buffer.getChannelData(0);
let state = 0x7FFF; // Initial non-zero state

for (let i = 0; i < data.length; i++) {
// x^15 + x^14 + 1 (15-bit)
const bit = ((state >> 14) ^ (state >> 13)) & 1;
state = ((state << 1) | bit) & 0x7FFF;
// Normalize to [-1, 1]
data[i] = (state / 16384) - 1.0;
}
return buffer;
}
21 changes: 13 additions & 8 deletions src/logic/drums/TR808Clap.ts
Original file line number Diff line number Diff line change
@@ -1,18 +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;
// Generate longer noise buffer once
this.noiseBuffer = generateWhiteNoise(Tone.getContext(), 1.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 - 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 @@ -25,21 +28,23 @@ export class TR808Clap {
const snapIntervalBase = 0.01;
const snapInterval = applyVariance(snapIntervalBase, 0.02);

const safeVelocity = Math.max(0.001, velocity);

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.setValueAtTime(safeVelocity, snapTime);
gain.gain.exponentialRampToValueAtTime(safeVelocity * 0.1, snapTime + snapInterval * 0.8);
}

// Final decay
const finalDecayStart = time + snapCount * snapInterval;
const decayTimeBase = 0.1 + decay * 0.5;
const decayTime = applyVariance(decayTimeBase, 0.02);

gain.gain.setValueAtTime(velocity, finalDecayStart);
gain.gain.setValueAtTime(safeVelocity, 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
22 changes: 13 additions & 9 deletions src/logic/drums/TR808Cowbell.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,14 @@ 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
const freq1 = applyPitchDrift(540 * pitchMultiplier, 2.0);
const freq2 = applyPitchDrift(800 * pitchMultiplier, 2.0);
Expand All @@ -30,10 +31,12 @@ export class TR808Cowbell {

const decayTime = applyVariance(0.1 + decay * 0.4, 0.02);

vca.gain.setValueAtTime(velocity, time);
const safeVelocity = Math.max(0.001, velocity);
vca.gain.setValueAtTime(safeVelocity, 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 +48,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();
}
}
25 changes: 14 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 @@ -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 @@ -48,34 +49,36 @@ export class TR808HiHat {
const decayTime = applyVariance(decayBase, 0.02);

// VCA Envelope
envGain.gain.setValueAtTime(velocity, time);
const safeVelocity = Math.max(0.001, velocity);
envGain.gain.setValueAtTime(safeVelocity, 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);
voice.oscillators.forEach(osc => osc.stop(time + 0.02));
});
this.activeGains.clear();
this.activeVoices.clear();
}
}
19 changes: 15 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 @@ -30,11 +32,20 @@ export class TR808Kick {
osc.frequency.setValueAtTime(startFreq, time);
osc.frequency.exponentialRampToValueAtTime(endFreq, time + 0.05);

// VCA Amp Envelope: Instant attack, adjustable exponential decay
masterGain.gain.setValueAtTime(velocity, time);
masterGain.gain.exponentialRampToValueAtTime(0.001, time + finalDecay);
// VCA Amp Envelope: Two-stage damping for authenticity
// Initial damping stage (diode-like behavior): fast decay to 50% volume in 20ms
const dampingTime = 0.02;
const midGain = Math.max(0.001, velocity * 0.5);
const safeVelocity = Math.max(0.001, velocity);

masterGain.gain.setValueAtTime(safeVelocity, time);
masterGain.gain.exponentialRampToValueAtTime(midGain, time + dampingTime);

// Final decay stage: from 50% to 0
const safeFinalDecay = Math.max(dampingTime + 0.01, finalDecay);
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
Loading