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
42 changes: 40 additions & 2 deletions 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,13 +30,51 @@ export function DrumsView() {
if (drumMachine) drumMachine.setSaturation(v)
}

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

// Ensure engine is synced after randomization
if (drumMachine) {
const state = useDrumStore.getState()
drumMachine.syncInternalParams(state.kit, state.drive, {
kick: state.kick,
snare: state.snare,
hihat: state.hihat,
hihatOpen: state.hihatOpen,
clap: state.clap,
cowbell: state.cowbell
})
}
}

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: '12px' }}>
<h3 style={{ margin: 0 }}>Настройки</h3>
<button
onClick={handleRandomize}
style={{
padding: '8px',
borderRadius: '50%',
background: 'rgba(0,0,0,0.05)',
color: 'var(--tg-theme-button-color)',
border: 'none',
display: 'flex',
alignItems: 'center',
justifyContent: 'center'
}}
title="Рандомизировать партию"
>
<Dices size={20} />
</button>
</div>
<div style={{ display: 'flex', gap: '12px', alignItems: 'center' }}>
<Knob
label="DRIVE"
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 }
}

/**
* Synchronizes all internal parameters with the provided application state.
*/
syncInternalParams(kit: '808' | '909', drive: number, drumParams: Record<string, { pitch: number, decay: number }>) {
this.setKit(kit);
this.setSaturation(drive);
for (const [drum, params] of Object.entries(drumParams)) {
this.setDrumParams(drum, params.pitch, params.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
27 changes: 27 additions & 0 deletions src/logic/DrumUtils.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,34 @@
import * as Tone from 'tone'

/**
* Shared DSP utilities for drum synthesis based on research specs.
*/

/**
* Generates an authentic 15-bit LFSR (Linear Feedback Shift Register) noise buffer.
* Uses the characteristic polynomial x^15 + x^{14} + 1.
* This emulates the digital crunch of vintage drum machines like the TR-909.
*/
export function generateLFSRNoise(context: Tone.BaseContext, 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 lfsr = 0x7FFF; // 15-bit seed (non-zero)

for (let i = 0; i < bufferSize; i++) {
// x^15 + x^14 + 1 -> feedback from bits 14 and 13 (0-indexed)
const bit = ((lfsr >> 14) ^ (lfsr >> 13)) & 1;
lfsr = ((lfsr << 1) | bit) & 0x7FFF;

// Convert to bipolar signal [-1, 1]
data[i] = ((lfsr & 1) * 2 - 1) * 0.5; // Softened a bit
}

return buffer;
}

/**
* Creates a soft-clipping saturation curve (hyperbolic tangent approximation).
* Formula: (3 + k) * x * 20 * deg / (Math.PI + k * Math.abs(x))
Expand Down
34 changes: 23 additions & 11 deletions src/logic/drums/TR808Cowbell.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ 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) { }

Expand Down Expand Up @@ -33,27 +33,39 @@ export class TR808Cowbell {
vca.gain.setValueAtTime(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);

osc1.onstop = () => {
let disposed = false;
const onEnd = () => {
if (disposed) return;
disposed = true;
osc1.dispose();
osc2.dispose();
mixGain.dispose();
bpf.dispose();
hpf.dispose();
vca.dispose();
this.activeGains.delete(vca);
this.activeVoices.delete(voice);
};

osc1.onstop = onEnd;

osc1.start(time).stop(time + decayTime);
osc2.start(time).stop(time + decayTime);

// Safety timeout for disposal
setTimeout(onEnd, (decayTime + 0.1) * 1000);
}

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();
}
}
40 changes: 26 additions & 14 deletions src/logic/drums/TR808HiHat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ 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) { }

Expand Down Expand Up @@ -51,31 +51,43 @@ export class TR808HiHat {
envGain.gain.setValueAtTime(velocity, time);
envGain.gain.exponentialRampToValueAtTime(0.001, time + decayTime);

this.activeGains.add(envGain);

// Scheduling
oscillators.forEach(osc => {
osc.start(time).stop(time + decayTime);
});
const voice = { oscillators, gain: envGain };
this.activeVoices.add(voice);

// Disposal - Explicitly clean up all 11-12 nodes to prevent memory leaks
// We use the first oscillator's onstop event to trigger the cleanup
oscillators[0].onstop = () => {
let disposed = false;
const onEnd = () => {
if (disposed) return;
disposed = true;
oscillators.forEach(o => o.dispose());
mixGain.dispose();
bpf1.dispose();
bpf2.dispose();
envGain.dispose();
hpf.dispose();
this.activeGains.delete(envGain);
this.activeVoices.delete(voice);
};

// We use the first oscillator's onstop event to trigger the cleanup
oscillators[0].onstop = onEnd;

// Scheduling
oscillators.forEach(osc => {
osc.start(time).stop(time + decayTime);
});

// Safety timeout for disposal (decayTime + 100ms)
setTimeout(onEnd, (decayTime + 0.1) * 1000);
}

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();
}
}
12 changes: 3 additions & 9 deletions src/logic/drums/TR909Snare.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, generateLFSRNoise } from '../DrumUtils'

export class TR909Snare {
private noiseBuffer: AudioBuffer;
Expand All @@ -9,14 +9,8 @@ export class TR909Snare {
// Soft Clipping curve from research
this.bodyCurve = makeDistortionCurve(15);

const sampleRate = Tone.getContext().sampleRate;
const bufferSize = sampleRate * 0.5;
this.noiseBuffer = Tone.getContext().createBuffer(1, bufferSize, sampleRate);
const data = this.noiseBuffer.getChannelData(0);
// While original used LFSR, research says Math.random() is sufficient for Web Audio API context
for (let i = 0; i < data.length; i++) {
data[i] = Math.random() * 2 - 1;
}
// Authentic 15-bit LFSR noise for 909 digital crunch
this.noiseBuffer = generateLFSRNoise(Tone.getContext(), 0.5);
}

trigger(time: number, pitch: number, snappy: number, velocity: number = 0.8) {
Expand Down
12 changes: 12 additions & 0 deletions src/store/audioStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import * as Tone from 'tone'
import { AcidSynth } from '../logic/AcidSynth'
import { DrumMachine } from '../logic/DrumMachine'
import { PadSynth } from '../logic/PadSynth'
import { useDrumStore } from './instrumentStore'

export interface AudioState {
isInitialized: boolean
Expand Down Expand Up @@ -63,6 +64,17 @@ export const useAudioStore = create<AudioState>((set, get) => ({
Tone.Transport.bpm.value = get().bpm
Tone.Transport.swing = get().swing

// Sync initial state from instrument store
const drumStore = useDrumStore.getState();
drums.syncInternalParams(drumStore.kit, drumStore.drive, {
kick: drumStore.kick,
snare: drumStore.snare,
hihat: drumStore.hihat,
hihatOpen: drumStore.hihatOpen,
clap: drumStore.clap,
cowbell: drumStore.cowbell
});

set({
isInitialized: true,
bassSynth: bassSynth,
Expand Down
15 changes: 12 additions & 3 deletions src/store/instrumentStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,22 +41,31 @@ interface DrumState {
setParams: (drum: 'kick' | 'snare' | 'hihat' | 'hihatOpen' | 'clap' | 'cowbell', params: Partial<DrumParams>) => void
setKit: (kit: '808' | '909') => void
setDrive: (drive: number) => void
randomizeDrums: () => void
}

export const useDrumStore = create<DrumState>((set) => ({
kick: { steps: 16, pulses: 4, rotate: 0, decay: 0.5, pitch: 0.5, probability: 1.0 },
snare: { steps: 16, pulses: 2, rotate: 4, decay: 0.5, pitch: 0.5, probability: 1.0 },
snare: { steps: 16, pulses: 4, rotate: 4, decay: 0.5, pitch: 0.5, probability: 1.0 },
hihat: { steps: 16, pulses: 12, rotate: 0, decay: 0.5, pitch: 0.5, probability: 1.0 },
hihatOpen: { steps: 16, pulses: 4, rotate: 2, decay: 0.5, pitch: 0.5, probability: 1.0 },
clap: { steps: 16, pulses: 2, rotate: 4, decay: 0.5, pitch: 0.5, probability: 1.0 },
cowbell: { steps: 16, pulses: 2, rotate: 2, decay: 0.5, pitch: 0.5, probability: 1.0 },
cowbell: { steps: 16, pulses: 3, rotate: 2, decay: 0.5, pitch: 0.5, probability: 0.8 },
kit: '909',
drive: 20,
setParams: (drum, params) => set((state) => ({
[drum]: { ...state[drum], ...params }
})),
setKit: (kit) => set({ kit }),
setDrive: (drive) => set({ drive })
setDrive: (drive) => set({ drive }),
randomizeDrums: () => set((state) => ({
kick: { ...state.kick, pulses: 3 + Math.floor(Math.random() * 3), rotate: Math.floor(Math.random() * 4) },
snare: { ...state.snare, pulses: 2 + Math.floor(Math.random() * 4), rotate: 4 + Math.floor(Math.random() * 4) },
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 Preserve randomized rotations in MIDI export

When the new dice action assigns nonzero rotate values here, playback and the drum visualizer use those rotations (SequencerLoop.tsx builds patterns with rotateArray(...)), but MIDI export in App.tsx still serializes each drum with plain bjorklund(...) and ignores rotate. After a user randomizes, especially when the kick rotation becomes 1–3, the exported MIDI drum hits no longer match what they heard in the app.

Useful? React with 👍 / 👎.

hihat: { ...state.hihat, pulses: 8 + Math.floor(Math.random() * 8) },
hihatOpen: { ...state.hihatOpen, pulses: 2 + Math.floor(Math.random() * 4) },
clap: { ...state.clap, pulses: 1 + Math.floor(Math.random() * 3), probability: 0.5 + Math.random() * 0.5 },
cowbell: { ...state.cowbell, pulses: 1 + Math.floor(Math.random() * 4), probability: 0.3 + Math.random() * 0.7 }
}))
}))

// Pad Store
Expand Down