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
36 changes: 35 additions & 1 deletion src/components/DrumsView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ 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 +30,23 @@ export function DrumsView() {
if (drumMachine) drumMachine.setSaturation(v)
}

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

// Sync new state to audio engine
if (drumMachine) {
const state = useDrumStore.getState()
const drums: ('kick' | 'snare' | 'hihat' | 'hihatOpen' | 'clap' | 'cowbell')[] = ['kick', 'snare', 'hihat', 'hihatOpen', 'clap', 'cowbell']
drums.forEach(id => {
drumMachine.setDrumParams(id, state[id].pitch, state[id].decay)
})
drumMachine.setSaturation(state.drive)
}
}

return (
<div style={{ display: 'flex', flexDirection: 'column', gap: '24px' }}>
<TransportControls title="Драм-машина" />
Expand All @@ -38,6 +55,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: 'var(--tg-theme-button-color)',
color: 'white',
border: 'none',
borderRadius: '8px',
padding: '8px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
cursor: 'pointer'
}}
>
<Dices size={20} />
</button>
<Knob
label="DRIVE"
value={drive}
Expand Down
6 changes: 6 additions & 0 deletions src/logic/DrumMachine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,12 @@ export class DrumMachine {
this.params[drum] = { pitch, decay }
}

syncInternalParams(kit: '808' | '909', saturation: number, params: Record<string, { pitch: number, decay: number }>) {
this.setKit(kit)
this.setSaturation(saturation)
this.params = { ...this.params, ...params }
}

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
38 changes: 38 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,39 @@ 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-second buffer of White Noise.
* Pre-generating avoids Math.random() calls during trigger.
*/
export function generateWhiteNoise(context: Tone.BaseContext): AudioBuffer {
const duration = 2.0;
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;
}

/**
* Generates an authentic 15-bit LFSR (Linear Feedback Shift Register) Noise.
* Used for TR-909 Snappy component. Polynomial: x^15 + x^14 + 1.
*/
export function generateLFSRNoise(context: Tone.BaseContext): AudioBuffer {
const duration = 2.0;
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
for (let i = 0; i < bufferSize; i++) {
const bit = ((reg >> 14) ^ (reg >> 13)) & 1;
reg = ((reg << 1) | bit) & 0x7FFF;
data[i] = (bit * 2 - 1);
}
return buffer;
}
20 changes: 11 additions & 9 deletions src/logic/drums/TR808Clap.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,20 @@
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;

// Use random start offset in the noise buffer 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 Leave enough noise buffer for max clap decay

When decay is high, this clap can be scheduled to play for roughly snapCount * snapInterval + (0.1 + decay * 0.5) seconds, up to about 0.69s, but this offset only guarantees 0.5s remains in the 2s buffer. In that setting, offsets above about 1.31s make the buffer source end before stop(finalDecayStart + decayTime), so hits randomly truncate and dispose the noise chain early instead of honoring the decay knob; bound the random offset by the actual scheduled duration or loop the noise source.

Useful? React with 👍 / 👎.

const noiseSrc = new Tone.BufferSource(this.noiseBuffer);

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 @@ -27,19 +29,19 @@ 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.setValueAtTime(Math.max(0.001, velocity), snapTime);
gain.gain.exponentialRampToValueAtTime(Math.max(0.001, velocity * 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(Math.max(0.001, 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: 12 additions & 8 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 @@ -30,10 +32,11 @@ export class TR808Cowbell {

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

vca.gain.setValueAtTime(velocity, time);
vca.gain.setValueAtTime(Math.max(0.001, 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 +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();
}
}
21 changes: 13 additions & 8 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 Down Expand Up @@ -48,10 +50,11 @@ export class TR808HiHat {
const decayTime = applyVariance(decayBase, 0.02);

// VCA Envelope
envGain.gain.setValueAtTime(velocity, time);
envGain.gain.setValueAtTime(Math.max(0.001, 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 => {
Expand All @@ -67,15 +70,17 @@ export class TR808HiHat {
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);
// Stop oscillators to be sure
voice.oscillators.forEach(osc => osc.stop(time + 0.02));
});
this.activeGains.clear();
this.activeVoices.clear();
}
}
4 changes: 3 additions & 1 deletion 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 @@ -31,7 +33,7 @@ export class TR808Kick {
osc.frequency.exponentialRampToValueAtTime(endFreq, time + 0.05);

// VCA Amp Envelope: Instant attack, adjustable exponential decay
masterGain.gain.setValueAtTime(velocity, time);
masterGain.gain.setValueAtTime(Math.max(0.001, velocity), time);
masterGain.gain.exponentialRampToValueAtTime(0.001, time + finalDecay);

osc.start(time).stop(time + finalDecay);
Expand Down
21 changes: 9 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,13 +36,14 @@ 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
const randomStart = Math.random() * (this.noiseBuffer.duration - 0.5);
const noiseSrc = new Tone.BufferSource(this.noiseBuffer);
// High-pass filter (>1800Hz) to prevent phase trap with tonal body
// Q = 0.707 (Butterworth)
Expand All @@ -61,12 +58,12 @@ export class TR808Snare {
noiseFilter.connect(snappyGain);
snappyGain.connect(this.destination);

snappyGain.gain.setValueAtTime(velocity * 0.8, time);
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
16 changes: 6 additions & 10 deletions src/logic/drums/TR909Kick.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, generateWhiteNoise } from '../DrumUtils'

export class TR909Kick {
private noiseBuffer: AudioBuffer;
Expand All @@ -8,17 +8,12 @@ export class TR909Kick {
constructor(private destination: Tone.ToneAudioNode) {
// Soft Clipping curve from research
this.bodyCurve = makeDistortionCurve(10);

const sampleRate = Tone.getContext().sampleRate;
const bufferSize = sampleRate * 0.05; // 50ms click
this.noiseBuffer = Tone.getContext().createBuffer(1, bufferSize, sampleRate);
const data = this.noiseBuffer.getChannelData(0);
for (let i = 0; i < bufferSize; 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;

// 909 Kick base frequency is fixed around 50Hz
const tune = 50;
// The 'Pitch' parameter on 909 maps to the frequency sweep duration
Expand Down Expand Up @@ -57,6 +52,7 @@ export class TR909Kick {
bodyGain.gain.exponentialRampToValueAtTime(0.001, time + vcaDecay);

// Click Layer (Noise)
const randomStart = Math.random() * (this.noiseBuffer.duration - 0.1);
const noiseSrc = new Tone.BufferSource(this.noiseBuffer);
const noiseFilter = new Tone.Filter(noiseFilterFreq, "highpass"); // HPF > 1kHz to avoid phase trap
const noiseGain = new Tone.Gain(0);
Expand All @@ -80,7 +76,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
Loading