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
30 changes: 29 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, randomizeTechno: randomizeTechnoStore } = useDrumStore()
const { drumMachine, volumes, setVolume } = useAudioStore()

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

const randomizeTechno = () => {
randomizeTechnoStore()

// Sync internal params after batch update
if (drumMachine) {
const state = useDrumStore.getState()
drumMachine.setDrumParams('kick', state.kick.pitch, state.kick.decay)
drumMachine.setDrumParams('snare', state.snare.pitch, state.snare.decay)
drumMachine.setDrumParams('hihat', state.hihat.pitch, state.hihat.decay)
drumMachine.setDrumParams('hihatOpen', state.hihatOpen.pitch, state.hihatOpen.decay)
drumMachine.setDrumParams('clap', state.clap.pitch, state.clap.decay)
drumMachine.setDrumParams('cowbell', state.cowbell.pitch, state.cowbell.decay)
}

// Telegram Haptic Feedback
if (window.Telegram?.WebApp?.HapticFeedback) {
window.Telegram.WebApp.HapticFeedback.impactOccurred('medium')
}
}

return (
<div style={{ display: 'flex', flexDirection: 'column', gap: '24px' }}>
<TransportControls title="Драм-машина" />
Expand All @@ -45,6 +65,14 @@ export function DrumsView() {
onChange={handleDriveChange}
size={40}
/>
<button
onClick={randomizeTechno}
className="icon-button"
style={{ padding: '8px' }}
title="Randomize Techno Patterns"
>
<Dices size={20} />
</button>
<div style={{ display: 'flex', gap: '4px', background: 'rgba(0,0,0,0.05)', padding: '4px', borderRadius: '8px' }}>
{(['808', '909'] as const).map(k => (
<button
Expand Down
16 changes: 16 additions & 0 deletions src/logic/DrumMachine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,22 @@ export class DrumMachine {
this.params[drum] = { pitch, decay }
}

syncInternalParams(kit: '808' | '909', drive: number, params: Record<string, { pitch: number, decay: number }>, volumes: Record<string, number>) {
this.setKit(kit)
this.setSaturation(drive)
Object.entries(params).forEach(([drum, p]) => {
this.setDrumParams(drum, p.pitch, p.decay)
})

// Sync volumes
if (volumes.kick) this.outputKick.gain.value = volumes.kick

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 Preserve zero volumes during sync

A value of 0 is a valid drum volume (the volume knobs allow muting), but this truthiness guard skips it. If the bulk sync runs with a muted channel—for example after restoring or preloading the audio store before constructing a new DrumMachine—the Gain node keeps its constructor default of 1, so the channel plays at full volume instead of staying muted; check for undefined/property presence rather than truthiness.

Useful? React with 👍 / 👎.

if (volumes.snare) this.outputSnare.gain.value = volumes.snare
if (volumes.hihat) this.outputHihat.gain.value = volumes.hihat
if (volumes.hihatOpen) this.outputOpenHat.gain.value = volumes.hihatOpen
if (volumes.clap) this.outputClap.gain.value = volumes.clap
if (volumes.cow) this.outputCowbell.gain.value = volumes.cow
}

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: 33 additions & 6 deletions src/logic/DrumUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,13 +21,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) in cents.
* Typically +/- 1-2 cents as per research.
* Formula: f_new = f_base * 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 for +/- 1 cent)
*/
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 cents = (Math.random() * centsRange) - (centsRange / 2);
return base * Math.pow(2, cents / 1200);
}

/**
Expand All @@ -37,6 +39,31 @@ 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]
// Math.random() * (variance * 2) - variance gives range [-variance, variance]
return base * (1 + (Math.random() * (variance * 2) - variance));
}

/**
* Generates an authentic 15-bit LFSR (Linear Feedback Shift Register) noise buffer.
* Characteristic polynomial: x^15 + x^14 + 1
* Used for TR-909 'Snappy' component to provide vintage digital texture.
*/
export function generateLFSRNoise(context: any, duration: number): AudioBuffer {
const sampleRate = context.sampleRate;
const bufferSize = sampleRate * duration;
const buffer = context.createBuffer(1, bufferSize, sampleRate);
const data = buffer.getChannelData(0);

let lfsr = 0x7FFF; // 15-bit seed (non-zero)

for (let i = 0; i < bufferSize; i++) {
// Feedback: bit 14 XOR bit 13 (0-indexed: x^15 is bit 14, x^14 is bit 13)
const bit = ((lfsr >> 14) ^ (lfsr >> 13)) & 1;
lfsr = ((lfsr << 1) | bit) & 0x7FFF;

// Convert 15-bit unsigned to [-1, 1] range
data[i] = (lfsr / 16383.5) - 1.0;
}

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
24 changes: 14 additions & 10 deletions src/logic/drums/TR808Cowbell.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,18 @@ 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);
const freq1 = applyPitchDrift(540 * pitchMultiplier, 4.0); // +/- 2 cents
const freq2 = applyPitchDrift(800 * pitchMultiplier, 4.0);

const osc1 = new Tone.Oscillator(freq1, "square");
const osc2 = new Tone.Oscillator(freq2, "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], 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();
}
}
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,7 +22,7 @@ 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, 4.0); // +/- 2 cents approx
const osc = new Tone.Oscillator(driftedFreq, "square");
osc.phase = Math.random() * 360;
osc.connect(mixGain);
Expand Down Expand Up @@ -48,34 +50,39 @@ 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 - Explicitly clean up all nodes to prevent memory leaks
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 they are scheduled with .stop(time + decayTime)
// But we can force stop them if needed:
voice.oscillators.forEach(osc => {
osc.stop(time + 0.02);
});
});
this.activeGains.clear();
this.activeVoices.clear();
}
}
25 changes: 17 additions & 8 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,29 @@ export class TR808Kick {
masterGain.connect(this.destination);

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

// Research mentions "Damping": Accelerated decay in initial phase due to diodes.
// We'll emulate this by having a two-stage amplitude envelope if needed,
// but a single exponential ramp is the standard digital approximation.
// Let's refine the pitch envelope to be more accurate.

// 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 (40-60ms) to simulate the membrane hit ('tonk')
const startFreq = tuneDrift * 2.5;
const endFreq = tuneDrift;
const pitchDecay = applyVariance(0.05, 0.02);

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

// VCA Amp Envelope: Instant attack, adjustable exponential decay
masterGain.gain.setValueAtTime(velocity, time);
masterGain.gain.exponentialRampToValueAtTime(0.001, time + finalDecay);
// The research says: V(t) = V0 * e^(-t/RC)
// We must use a value > 0 for the end of exponentialRampToValueAtTime
const vcaDecay = applyVariance(decayTime, 0.02);
masterGain.gain.setValueAtTime(Math.max(0.001, velocity), time);
masterGain.gain.exponentialRampToValueAtTime(0.001, time + vcaDecay);

osc.start(time).stop(time + finalDecay);
osc.start(time).stop(time + vcaDecay);

osc.onstop = () => {
osc.dispose();
Expand Down
15 changes: 10 additions & 5 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 @@ -25,8 +27,8 @@ export class TR808Snare {
const noiseFilterFreq = applyVariance(1800, 0.02);

// 808 Membrane modes: fixed at ~238Hz and ~476Hz according to research
const oscLow = new Tone.Oscillator(applyPitchDrift(238, 1.0), "sine");
const oscHigh = new Tone.Oscillator(applyPitchDrift(476, 1.0), "sine");
const oscLow = new Tone.Oscillator(applyPitchDrift(238, 2.0), "sine");
const oscHigh = new Tone.Oscillator(applyPitchDrift(476, 2.0), "sine");
oscLow.phase = Math.random() * 360;
oscHigh.phase = Math.random() * 360;

Expand All @@ -40,10 +42,13 @@ 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);
const v0_low = Math.max(0.001, velocity * (1 - toneBalance));
const v0_high = Math.max(0.001, velocity * toneBalance);

gainLow.gain.setValueAtTime(v0_low, time);
gainLow.gain.exponentialRampToValueAtTime(0.001, time + vcaDecay);

gainHigh.gain.setValueAtTime(velocity * toneBalance, time);
gainHigh.gain.setValueAtTime(v0_high, time);
gainHigh.gain.exponentialRampToValueAtTime(0.001, time + vcaDecay * 0.75);

// Snappy Layer
Expand All @@ -61,7 +66,7 @@ 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);
Expand Down
Loading