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
34 changes: 33 additions & 1 deletion src/components/DrumsView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,22 @@ 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 handleRandomize = () => {
randomizeDrums()
const state = useDrumStore.getState()
const drumList: ('kick' | 'snare' | 'hihat' | 'hihatOpen' | 'clap' | 'cowbell')[] = ['kick', 'snare', 'hihat', 'hihatOpen', 'clap', 'cowbell']
drumList.forEach(id => {
if (drumMachine) drumMachine.setDrumParams(id, state[id].pitch, state[id].decay)
})

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

const updateDrum = (drum: 'kick' | 'snare' | 'hihat' | 'hihatOpen' | 'clap' | 'cowbell', params: Partial<DrumParams>) => {
setParams(drum, params)
if (drumMachine) {
Expand Down Expand Up @@ -38,6 +51,25 @@ export function DrumsView() {
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<h3 style={{ margin: 0 }}>Настройки</h3>
<div style={{ display: 'flex', gap: '12px', alignItems: 'center' }}>
<button
onClick={handleRandomize}
aria-label="randomize"
style={{
background: 'var(--tg-theme-button-color)',
border: 'none',
borderRadius: '50%',
width: '36px',
height: '36px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
cursor: 'pointer',
color: 'white',
boxShadow: '0 2px 8px rgba(0,0,0,0.2)'
}}
>
<Dices size={20} />
</button>
<Knob
label="DRIVE"
value={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 downbeat
if (step === 0 && window.Telegram?.WebApp?.HapticFeedback) {
window.Telegram.WebApp.HapticFeedback.impactOccurred('light')
}

// Access current state directly from store to avoid loop restarts
const currentBass = useBassStore.getState()
const currentSeq = useSequencerStore.getState()
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', saturation: number, drumParams: Record<string, { pitch: number, decay: number }>) {
this.setKit(kit)
this.setSaturation(saturation)
Object.entries(drumParams).forEach(([drum, p]) => {
this.setDrumParams(drum as any, 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
40 changes: 40 additions & 0 deletions src/logic/DrumUtils.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,47 @@
import * as Tone from 'tone'

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

/**
* Generates a White Noise AudioBuffer.
* @param context - Audio context
* @param duration - Duration in seconds
*/
export function generateWhiteNoise(context: Tone.BaseContext, duration: number = 2.0): AudioBuffer {
const sampleRate = context.sampleRate;
const bufferSize = sampleRate * duration;
const buffer = context.createBuffer(1, bufferSize, sampleRate);
const data = buffer.getChannelData(0);
for (let i = 0; i < bufferSize; i++) {
data[i] = Math.random() * 2 - 1;
}
return buffer;
}

/**
* Generates an LFSR (Linear Feedback Shift Register) Noise AudioBuffer.
* Implements a 15-bit LFSR with polynomial x^15 + x^14 + 1 for authentic TR-909 textures.
* @param context - Audio context
* @param duration - Duration in seconds
*/
export function generateLFSRNoise(context: Tone.BaseContext, duration: number = 2.0): AudioBuffer {
const sampleRate = context.sampleRate;
const bufferSize = sampleRate * duration;
const buffer = context.createBuffer(1, bufferSize, sampleRate);
const data = buffer.getChannelData(0);

let state = 0x7FFF;
for (let i = 0; i < bufferSize; i++) {
// Taps at bit 14 and 13 (0-indexed) for x^15 + x^14 + 1
const bit = ((state >> 14) ^ (state >> 13)) & 1;
state = ((state << 1) | bit) & 0x7FFF;
data[i] = (state & 1) ? 0.5 : -0.5; // Normalized amplitude
}
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
7 changes: 2 additions & 5 deletions src/logic/drums/TR808Clap.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,11 @@
import * as Tone from 'tone'
import { applyVariance } from '../DrumUtils'
import { applyVariance, generateWhiteNoise } from '../DrumUtils'

export class TR808Clap {
private noiseBuffer: AudioBuffer;

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

trigger(time: number, pitch: number, decay: number, velocity: number = 0.8) {
Expand Down
10 changes: 2 additions & 8 deletions src/logic/drums/TR808Snare.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,11 @@
import * as Tone from 'tone'
import { applyPitchDrift, applyVariance } from '../DrumUtils'
import { applyPitchDrift, applyVariance, generateWhiteNoise } from '../DrumUtils'

export class TR808Snare {
private noiseBuffer: AudioBuffer;

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

trigger(time: number, pitch: number, snappy: number, velocity: number = 0.8) {
Expand Down
11 changes: 2 additions & 9 deletions src/logic/drums/TR909Kick.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, generateWhiteNoise } from '../DrumUtils'

export class TR909Kick {
private noiseBuffer: AudioBuffer;
Expand All @@ -8,14 +8,7 @@ export class TR909Kick {
constructor(private destination: Tone.ToneAudioNode) {
// Soft Clipping curve from research
this.bodyCurve = makeDistortionCurve(10);

const sampleRate = Tone.getContext().sampleRate;
const bufferSize = sampleRate * 0.05; // 50ms click
this.noiseBuffer = Tone.getContext().createBuffer(1, bufferSize, sampleRate);
const data = this.noiseBuffer.getChannelData(0);
for (let i = 0; i < bufferSize; i++) {
data[i] = Math.random() * 2 - 1;
}
this.noiseBuffer = generateWhiteNoise(Tone.getContext(), 0.05); // 50ms click
}

trigger(time: number, pitch: number, decay: number, velocity: number = 0.8) {
Expand Down
18 changes: 5 additions & 13 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 @@ -8,15 +8,7 @@ export class TR909Snare {
constructor(private destination: Tone.ToneAudioNode) {
// 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;
}
this.noiseBuffer = generateLFSRNoise(Tone.getContext(), 0.5);
}

trigger(time: number, pitch: number, snappy: number, velocity: number = 0.8) {
Expand Down Expand Up @@ -50,10 +42,10 @@ export class TR909Snare {
postShaperGain.connect(tonalGain);
tonalGain.connect(this.destination);

// Pitch Sweep: ~320Hz to ~160Hz over 30ms (as per research spec)
// Pitch Sweep: ~320Hz to ~160Hz over 20-30ms (as per research spec)
const sweepTime = 0.03;
const startFreq1 = toneDrift1 * 2;
const startFreq2 = toneDrift2 * 2;
const startFreq1 = toneDrift1 * 2; // ~320Hz
const startFreq2 = toneDrift2 * 2; // ~440Hz (maintaining dissonance)

osc1.frequency.setValueAtTime(startFreq1, time);
osc1.frequency.exponentialRampToValueAtTime(toneDrift1, time + sweepTime);
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 drum parameters
const drumState = useDrumStore.getState()
drums.syncInternalParams(drumState.kit, drumState.drive, {
kick: drumState.kick,
snare: drumState.snare,
hihat: drumState.hihat,
hihatOpen: drumState.hihatOpen,
clap: drumState.clap,
cowbell: drumState.cowbell
})

set({
isInitialized: true,
bassSynth: bassSynth,
Expand Down
18 changes: 15 additions & 3 deletions src/store/instrumentStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,22 +41,34 @@ 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) => {
const randRange = (min: number, max: number) => Math.floor(Math.random() * (max - min + 1)) + min;
return {
kick: { ...state.kick, pulses: randRange(3, 5), rotate: randRange(0, 3) },
snare: { ...state.snare, pulses: randRange(2, 6), rotate: randRange(2, 6) },
hihat: { ...state.hihat, pulses: randRange(8, 14), rotate: randRange(0, 4) },
hihatOpen: { ...state.hihatOpen, pulses: randRange(2, 6), rotate: randRange(0, 8) },
clap: { ...state.clap, pulses: randRange(1, 4), rotate: randRange(4, 12) },
cowbell: { ...state.cowbell, pulses: randRange(2, 8), rotate: randRange(0, 15) }
}
})
}))

// Pad Store
Expand Down