From aec375d52f9dbd6cfdc8b69ba5d661eaef106876 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Tue, 23 Jun 2026 02:34:40 +0000 Subject: [PATCH] feat: implement authentic DSP drum synthesis and randomization - Added 15-bit LFSR noise generator in DrumUtils for TR-909 digital crunch. - Updated TR909Snare to use authentic LFSR noise. - Implemented state synchronization between UI and Audio Engine. - Added musical Euclidean pattern randomization for a "Drum Party" experience. - Refined Web Audio node disposal in Hi-Hat and Cowbell to prevent memory leaks. - Integrated randomization UI with Telegram Haptic Feedback. Co-authored-by: Pitrat-wav <255843145+Pitrat-wav@users.noreply.github.com> --- src/components/DrumsView.tsx | 42 +++++++++++++++++++++++++++++++-- src/logic/DrumMachine.ts | 11 +++++++++ src/logic/DrumUtils.ts | 27 +++++++++++++++++++++ src/logic/drums/TR808Cowbell.ts | 34 +++++++++++++++++--------- src/logic/drums/TR808HiHat.ts | 40 ++++++++++++++++++++----------- src/logic/drums/TR909Snare.ts | 12 +++------- src/store/audioStore.ts | 12 ++++++++++ src/store/instrumentStore.ts | 15 +++++++++--- 8 files changed, 154 insertions(+), 39 deletions(-) diff --git a/src/components/DrumsView.tsx b/src/components/DrumsView.tsx index 7d191e90..f4b7bcc9 100644 --- a/src/components/DrumsView.tsx +++ b/src/components/DrumsView.tsx @@ -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) => { @@ -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 (
-

Настройки

+
+

Настройки

+ +
) { + 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 diff --git a/src/logic/DrumUtils.ts b/src/logic/DrumUtils.ts index b9917c79..d28e573b 100644 --- a/src/logic/DrumUtils.ts +++ b/src/logic/DrumUtils.ts @@ -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)) diff --git a/src/logic/drums/TR808Cowbell.ts b/src/logic/drums/TR808Cowbell.ts index 3dd1d4fd..56defd62 100644 --- a/src/logic/drums/TR808Cowbell.ts +++ b/src/logic/drums/TR808Cowbell.ts @@ -2,7 +2,7 @@ import * as Tone from 'tone' import { applyPitchDrift, applyVariance } from '../DrumUtils' export class TR808Cowbell { - private activeGains: Set = new Set(); + private activeVoices: Set<{ oscillators: Tone.Oscillator[], gain: Tone.Gain }> = new Set(); constructor(private destination: Tone.ToneAudioNode) { } @@ -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(); } } diff --git a/src/logic/drums/TR808HiHat.ts b/src/logic/drums/TR808HiHat.ts index acd1216d..e776fbf8 100644 --- a/src/logic/drums/TR808HiHat.ts +++ b/src/logic/drums/TR808HiHat.ts @@ -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 = new Set(); + private activeVoices: Set<{ oscillators: Tone.Oscillator[], gain: Tone.Gain }> = new Set(); constructor(private destination: Tone.ToneAudioNode) { } @@ -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(); } } diff --git a/src/logic/drums/TR909Snare.ts b/src/logic/drums/TR909Snare.ts index 4841f055..c5171202 100644 --- a/src/logic/drums/TR909Snare.ts +++ b/src/logic/drums/TR909Snare.ts @@ -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; @@ -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) { diff --git a/src/store/audioStore.ts b/src/store/audioStore.ts index c90d17e8..75c78f5e 100644 --- a/src/store/audioStore.ts +++ b/src/store/audioStore.ts @@ -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 @@ -63,6 +64,17 @@ export const useAudioStore = create((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, diff --git a/src/store/instrumentStore.ts b/src/store/instrumentStore.ts index 9e96a9b2..9f5d128e 100644 --- a/src/store/instrumentStore.ts +++ b/src/store/instrumentStore.ts @@ -41,22 +41,31 @@ interface DrumState { setParams: (drum: 'kick' | 'snare' | 'hihat' | 'hihatOpen' | 'clap' | 'cowbell', params: Partial) => void setKit: (kit: '808' | '909') => void setDrive: (drive: number) => void + randomizeDrums: () => void } export const useDrumStore = create((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) }, + 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