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
22 changes: 18 additions & 4 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, applyTechnoTemplate } = useDrumStore()
const { drumMachine, volumes, setVolume } = useAudioStore()

const updateDrum = (drum: 'kick' | 'snare' | 'hihat' | 'hihatOpen' | 'clap' | 'cowbell', params: Partial<DrumParams>) => {
Expand All @@ -30,13 +28,29 @@ export function DrumsView() {
if (drumMachine) drumMachine.setSaturation(v)
}

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

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={handleApplyTemplate}
style={{ background: 'none', border: 'none', cursor: 'pointer', display: 'flex', color: 'var(--tg-theme-button-color)' }}
title="Apply Techno Template"
>
<Dices size={20} />
</button>
</div>
<div style={{ display: 'flex', gap: '12px', alignItems: 'center' }}>
<Knob
label="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 on the downbeat for immersion
if (step === 0 && window.Telegram?.WebApp?.HapticFeedback) {
window.Telegram.WebApp.HapticFeedback.impactOccurred('light')
Comment on lines +62 to +63

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 Schedule downbeat haptics at the audio time

For Telegram users with haptics enabled, this runs impactOccurred immediately when Tone schedules the loop callback, not when playback reaches the supplied time. That means the vibration can lead the actual downbeat by Tone's lookahead even though the UI playhead below is correctly synchronized with Tone.Draw.schedule(..., time), so the rhythm feedback feels early. Schedule the haptic call against time as well instead of firing it inline.

Useful? React with 👍 / 👎.

}

// Access current state directly from store to avoid loop restarts
const currentBass = useBassStore.getState()
const currentSeq = useSequencerStore.getState()
Expand Down
37 changes: 31 additions & 6 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,14 @@ 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.
* 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 2.0, meaning +/- 2 cents)
*/
export function applyPitchDrift(base: number, range: number = 1.0): number {
return base + (Math.random() * 2 - 1) * range;
export function applyPitchDrift(base: number, centsRange: number = 2.0): number {
const driftCents = (Math.random() * 2 - 1) * centsRange;
return base * Math.pow(2, driftCents / 1200);
}

/**
Expand All @@ -37,6 +40,28 @@ export function applyPitchDrift(base: number, range: number = 1.0): number {
* @param variance - Variance percentage (e.g. 0.02 for 2%)
*/
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.
* Polynomial: x^15 + x^14 + 1
* @param context - AudioContext to create the buffer
* @param duration - Duration in seconds
*/
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 reg = 0x7FFF; // 15-bit register, start with non-zero

for (let i = 0; i < bufferSize; i++) {
const bit = ((reg >> 0) ^ (reg >> 1)) & 1;
reg = (reg >> 1) | (bit << 14);
data[i] = (bit * 2) - 1;
}

return buffer;
}
12 changes: 7 additions & 5 deletions src/logic/drums/TR808Clap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,14 @@ export class TR808Clap {

constructor(private destination: Tone.ToneAudioNode) {
const sampleRate = Tone.getContext().sampleRate;
this.noiseBuffer = Tone.getContext().createBuffer(1, sampleRate * 0.5, sampleRate);
this.noiseBuffer = Tone.getContext().createBuffer(1, Math.floor(sampleRate * 0.5), sampleRate);
const data = this.noiseBuffer.getChannelData(0);
for (let i = 0; i < data.length; i++) data[i] = Math.random() * 2 - 1;
}

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,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).stop(finalDecayStart + decayTime + 0.1);

noiseSrc.onended = () => {
noiseSrc.dispose();
Expand Down
24 changes: 14 additions & 10 deletions src/logic/drums/TR808Cowbell.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,18 @@
import * as Tone from 'tone'
import { applyPitchDrift, applyVariance } from '../DrumUtils'
import { applyVariance, applyPitchDrift } from '../DrumUtils'

export class TR808Cowbell {
private activeGains: Set<Tone.Gain> = new Set();
private activeVoices: Set<{ oscillators: Tone.Oscillator[], vca: Tone.Gain, mixGain: Tone.Gain, bpf: Tone.Filter, hpf: Tone.Filter }> = 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 freq1 = applyPitchDrift(540 * pitchMultiplier, 2.0); // +/- 2 cents
const freq2 = applyPitchDrift(800 * pitchMultiplier, 2.0);

const osc1 = new Tone.Oscillator(freq1, "square");
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], vca, mixGain, bpf, hpf };
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.vca.gain.cancelScheduledValues(time);
voice.vca.gain.exponentialRampToValueAtTime(0.001, time + 0.02);
voice.oscillators.forEach(osc => osc.stop(time + 0.02));
});
this.activeGains.clear();
this.activeVoices.clear();
}
}
28 changes: 16 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[], envGain: Tone.Gain, mixGain: Tone.Gain, bpf1: Tone.Filter, bpf2: Tone.Filter, hpf: Tone.Filter }> = 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,37 @@ 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, envGain, mixGain, bpf1, bpf2, hpf };
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.envGain.gain.cancelScheduledValues(time);
voice.envGain.gain.exponentialRampToValueAtTime(0.001, time + 0.02);
// Oscillators will stop naturally because of .stop(time + decayTime) scheduled in trigger
// But if we want immediate stop, we could call .stop(time + 0.02) on oscillators too.
voice.oscillators.forEach(osc => osc.stop(time + 0.02));
});
this.activeGains.clear();
this.activeVoices.clear();
}
}
26 changes: 18 additions & 8 deletions src/logic/drums/TR808Kick.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,36 +5,46 @@ 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
const decayTime = 0.4 + decay * 2.6;

// 808 Kick Core: Bridged-T Network emulation (Sine wave)
const osc = new Tone.Oscillator(tune, "sine");
osc.phase = Math.random() * 360; // Analog phase randomization
osc.phase = Math.random() * 360;
const masterGain = new Tone.Gain(0);

osc.connect(masterGain);
masterGain.connect(this.destination);

// Micro-randomization using shared utilities
const tuneDrift = applyPitchDrift(tune, 1.0);
const tuneDrift = applyPitchDrift(tune, 2.0); // +/- 2 cents
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
// Pitch Envelope: Start high (Tune * 2.5) and drop quickly (50ms)
const startFreq = tuneDrift * 2.5;
const endFreq = tuneDrift;

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: Diode damping emulation
// Research: "Ускоренное затухание резонанса в начальной фазе, формирующее плотный транзиент."
// Two-stage envelope: fast 20ms initial decay to 50% volume, then the main decay.
const dampingTime = 0.02;
const dampingVolume = velocity * 0.5;

masterGain.gain.setValueAtTime(Math.max(0.001, velocity), time);
masterGain.gain.exponentialRampToValueAtTime(Math.max(0.001, dampingVolume), time + dampingTime);

// Ensure finalDecay is always > dampingTime
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