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

const randomizeTechno = () => {
// Haptic Feedback for randomization
if (window.Telegram?.WebApp?.HapticFeedback) {
window.Telegram.WebApp.HapticFeedback.impactOccurred('medium')
}

// Apply Techno Foundation defaults
updateDrum('kick', { steps: 16, pulses: 4, rotate: 0, probability: 1.0 })
updateDrum('snare', { steps: 16, pulses: 4, rotate: 4, probability: 1.0 })
updateDrum('hihat', { steps: 16, pulses: 12, rotate: 0, probability: 1.0 })
updateDrum('hihatOpen', { steps: 16, pulses: 4, rotate: 2, probability: 1.0 })
updateDrum('clap', { steps: 16, pulses: 2, rotate: 4, probability: 1.0 })
updateDrum('cowbell', { steps: 16, pulses: 3, rotate: 2, probability: 0.8 })
}

return (
<div style={{ display: 'flex', flexDirection: 'column', gap: '24px' }}>
<TransportControls title="Драм-машина" />

<section className="card">
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<h3 style={{ margin: 0 }}>Настройки</h3>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<h3 style={{ margin: 0 }}>Настройки</h3>
<button
onClick={randomizeTechno}
style={{
background: 'rgba(0,0,0,0.05)',
border: 'none',
padding: '6px',
borderRadius: '8px',
display: 'flex',
alignItems: 'center',
cursor: 'pointer'
}}
title="Randomize Techno"
>
<Dices size={16} color="var(--tg-theme-button-color)" />
</button>
</div>
<div style={{ display: 'flex', gap: '12px', alignItems: 'center' }}>
<Knob
label="DRIVE"
Expand Down Expand Up @@ -105,9 +137,9 @@ export function DrumsView() {
/>
<Knob
label="Vol"
value={volumes[d.id === 'cowbell' ? 'cow' : d.id]}
value={volumes[d.id === 'cowbell' ? 'cowbell' : d.id]}
min={0} max={1} step={0.01}
onChange={(v) => setVolume(d.id === 'cowbell' ? 'cow' : d.id, v)}
onChange={(v) => setVolume(d.id === 'cowbell' ? 'cowbell' : d.id, v)}
size={40}
/>
</div>
Expand Down
6 changes: 3 additions & 3 deletions src/components/MixerView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -58,10 +58,10 @@ export function MixerView() {
size={48}
/>
<Knob
label="Cow"
value={volumes.cow}
label="Cowbell"
value={volumes.cowbell}
min={0} max={1} step={0.01}
onChange={(v) => setVolume('cow', v)}
onChange={(v) => setVolume('cowbell', v)}
size={48}
/>
<Knob
Expand Down
4 changes: 4 additions & 0 deletions src/components/SequencerLoop.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,10 @@ export function SequencerLoop() {
}
}

if (step === 0 && window.Telegram?.WebApp?.HapticFeedback) {
window.Telegram.WebApp.HapticFeedback.impactOccurred('light')
}

triggerDrumWithProb('kick')
triggerDrumWithProb('snare')
triggerDrumWithProb('hihat')
Expand Down
33 changes: 29 additions & 4 deletions src/logic/DrumUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,12 +22,14 @@ export function makeDistortionCurve(amount: number = 20): Float32Array {

/**
* Applies micro-randomization to a base frequency (Pitch Drift).
* Typically +/- 1Hz as per research.
* Uses cent-based logarithmic frequency deviation.
* 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 cents - Drift range in cents (default +/- 1 cent)
*/
export function applyPitchDrift(base: number, range: number = 1.0): number {
return base + (Math.random() * 2 - 1) * range;
export function applyPitchDrift(base: number, cents: number = 1.0): number {
const drift = (Math.random() * 2 - 1) * cents;
return base * Math.pow(2, drift / 1200);
}

/**
Expand All @@ -40,3 +42,26 @@ 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 (Linear Feedback Shift Register) pseudo-random noise buffer.
* Uses characteristic polynomial x^15 + x^14 + 1 to emulate vintage digital textures (e.g. TR-909 Snappy).
* @param context - Web Audio Context (any used to bypass Tone.js type mismatch)
* @param duration - Duration in seconds
*/
export function generateLFSRNoise(context: any, duration: number): AudioBuffer {
const sampleRate = context.sampleRate;
const bufferSize = Math.floor(sampleRate * duration);
const buffer = context.createBuffer(1, bufferSize, sampleRate);
const data = buffer.getChannelData(0);

let state = 0x7FFF; // Initial 15-bit state (cannot be 0)
for (let i = 0; i < bufferSize; i++) {
// LFSR with taps at 15 and 14 (0-indexed: 14 and 13)
// bit = bit15 XOR bit14
const bit = ((state >> 14) ^ (state >> 13)) & 1;
state = ((state << 1) | bit) & 0x7FFF;
data[i] = (state & 1) ? 1 : -1;
}
return buffer;
}
8 changes: 5 additions & 3 deletions src/logic/drums/TR808Clap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ export class TR808Clap {
}

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 @@ -27,16 +29,16 @@ 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);
Expand Down
22 changes: 14 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 @@ -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 @@ -30,10 +34,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 +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();
}
}
26 changes: 14 additions & 12 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); // +/- 2 cents
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 @@ -48,34 +49,35 @@ 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 => {
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();
}
}
23 changes: 17 additions & 6 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 @@ -19,22 +21,31 @@ export class TR808Kick {
masterGain.connect(this.destination);

// Micro-randomization using shared utilities
const tuneDrift = applyPitchDrift(tune, 1.0);
const tuneDrift = applyPitchDrift(tune, 1.0); // +/- 1 cent
const finalDecay = applyVariance(decayTime, 0.02);

// 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
// Two-stage Pitch Envelope:
// 1. Rapid drop (membrane hit 'tonk') from startFreq to midFreq in 20ms
// 2. Slower drop to final tune in another 30ms (total 50ms sweep)
const startFreq = tuneDrift * 2.5;
const midFreq = tuneDrift * 1.2;
const endFreq = tuneDrift;

osc.frequency.setValueAtTime(startFreq, time);
osc.frequency.exponentialRampToValueAtTime(midFreq, time + 0.02);
osc.frequency.exponentialRampToValueAtTime(endFreq, time + 0.05);

// VCA Amp Envelope: Instant attack, adjustable exponential decay
// Two-stage VCA Amp Envelope:
// 1. Fast damping (20ms) to 50% volume (emulating diode damping)
// 2. Long exponential decay to zero
const dampingTime = 0.02;
const safeFinalDecay = Math.max(dampingTime + 0.01, finalDecay);

masterGain.gain.setValueAtTime(velocity, time);
masterGain.gain.exponentialRampToValueAtTime(0.001, time + finalDecay);
masterGain.gain.exponentialRampToValueAtTime(velocity * 0.5, time + dampingTime);
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
10 changes: 7 additions & 3 deletions src/logic/drums/TR808Snare.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ export class TR808Snare {
}

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 +42,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,11 +63,13 @@ 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);

// disposal is anchored to the noise source
noiseSrc.start(time).stop(time + snappyDecay + 0.1);

// Cleanup
Expand Down
Loading