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, cowbell, kit, drive, setParams, setKit, setDrive, randomizeTechno } = 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 = () => {
if (window.Telegram?.WebApp?.HapticFeedback) {
window.Telegram.WebApp.HapticFeedback.impactOccurred('medium')
}
randomizeTechno()

if (drumMachine) {
const s = useDrumStore.getState()
drumMachine.syncInternalParams(s.kit, s.drive, {
kick: s.kick,
snare: s.snare,
hihat: s.hihat,
hihatOpen: s.hihatOpen,
clap: s.clap,
cowbell: s.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', gap: '8px', alignItems: 'center' }}>
<h3 style={{ margin: 0 }}>Настройки</h3>
<button
onClick={handleRandomize}
style={{
border: 'none',
background: 'rgba(0,0,0,0.05)',
borderRadius: '8px',
width: '32px',
height: '32px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: 'var(--tg-theme-button-color)',
cursor: 'pointer'
}}
>
<Dices size={18} />
</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 @@ -86,6 +86,11 @@ export function SequencerLoop() {
triggerDrumWithProb('clap')
triggerDrumWithProb('cowbell')

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

// 2. Bass (Sting logic)
const bassStep = currentBass.pattern[step]
const prevBassStep = currentBass.pattern[(step + 15) % 16]
Expand Down
8 changes: 8 additions & 0 deletions src/logic/DrumMachine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,14 @@ export class DrumMachine {
this.params[drum] = { pitch, decay }
}

syncInternalParams(kit: '808' | '909', drive: number, drumParams: Record<string, { pitch: number, decay: number }>) {
this.setKit(kit)
this.setSaturation(drive)
Object.entries(drumParams).forEach(([drum, p]) => {
this.setDrumParams(drum, p.pitch, p.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
25 changes: 25 additions & 0 deletions src/logic/DrumUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,3 +40,28 @@ 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 pseudo-random digital noise buffer using LFSR.
* Used for TR-909 snappy and digital textures.
* Polynomial: x^15 + x^14 + 1
*/
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 state, initialized to non-zero

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

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

return buffer;
}
15 changes: 10 additions & 5 deletions src/logic/drums/TR808Kick.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,19 +22,24 @@ export class TR808Kick {
const tuneDrift = applyPitchDrift(tune, 1.0);
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: 5ms click + 45ms sweep for authentic 'tonk'
const startFreq = tuneDrift * 2.5;
const midFreq = tuneDrift * 1.5;
const endFreq = tuneDrift;

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

// VCA Amp Envelope: Instant attack, adjustable exponential decay
// Two-stage Amp Envelope: 20ms diode damping (decay to 50%) followed by long final decay
masterGain.gain.setValueAtTime(velocity, time);
masterGain.gain.exponentialRampToValueAtTime(0.001, time + finalDecay);
masterGain.gain.exponentialRampToValueAtTime(velocity * 0.5, time + 0.02);

osc.start(time).stop(time + finalDecay);
// Ensure final decay is always longer than the damping stage
const safeFinalDecay = Math.max(0.021, finalDecay);
masterGain.gain.exponentialRampToValueAtTime(0.001, time + safeFinalDecay);

osc.start(time).stop(time + safeFinalDecay);

osc.onstop = () => {
osc.dispose();
Expand Down
4 changes: 2 additions & 2 deletions src/logic/drums/TR909Kick.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,8 @@ export class TR909Kick {
private bodyCurve: Float32Array;

constructor(private destination: Tone.ToneAudioNode) {
// Soft Clipping curve from research
this.bodyCurve = makeDistortionCurve(10);
// Soft Clipping curve from research (amount 30 for authentic 909 drive)
this.bodyCurve = makeDistortionCurve(30);

const sampleRate = Tone.getContext().sampleRate;
const bufferSize = sampleRate * 0.05; // 50ms click
Expand Down
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 texture
this.noiseBuffer = generateLFSRNoise(Tone.getContext(), 0.5);
}

trigger(time: number, pitch: number, snappy: number, velocity: number = 0.8) {
Expand Down
6 changes: 6 additions & 0 deletions src/store/audioStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,12 @@ export const useAudioStore = create<AudioState>((set, get) => ({
Tone.Transport.bpm.value = get().bpm
Tone.Transport.swing = get().swing

// Sync initial state from instrument store to drum engine
const { kit, drive, kick, snare, hihat, hihatOpen, clap, cowbell } = (await import('./instrumentStore')).useDrumStore.getState()
drums.syncInternalParams(kit, drive, {
kick, snare, hihat, hihatOpen, clap, cowbell
})

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

export const useDrumStore = create<DrumState>((set) => ({
Expand All @@ -56,7 +57,16 @@ export const useDrumStore = create<DrumState>((set) => ({
[drum]: { ...state[drum], ...params }
})),
setKit: (kit) => set({ kit }),
setDrive: (drive) => set({ drive })
setDrive: (drive) => set({ drive }),
randomizeTechno: () => set((state) => ({
kick: { ...state.kick, pulses: 4, rotate: 0, probability: 1.0 },
snare: { ...state.snare, pulses: 4, rotate: 4, probability: 1.0 },
hihat: { ...state.hihat, pulses: 12, rotate: 0, probability: 1.0 },
hihatOpen: { ...state.hihatOpen, pulses: 4, rotate: 2, probability: 1.0 },
cowbell: { ...state.cowbell, pulses: 3, rotate: 2, probability: 0.8 },
Comment on lines +62 to +66

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 Include clap in the techno randomizer

This partial Zustand update resets every drum lane except clap, and handleRandomize then syncs the engine with the unchanged s.clap. If a user previously changed the clap pulses/probability, especially to 0, pressing the dice leaves the clap stale or silent while the rest of the techno kit is reset, so add a clap entry to the randomized state.

Useful? React with 👍 / 👎.

kit: '909',
drive: 30
}))
}))

// Pad Store
Expand Down