diff --git a/src/components/DrumsView.tsx b/src/components/DrumsView.tsx index 7d191e90..9b335bf0 100644 --- a/src/components/DrumsView.tsx +++ b/src/components/DrumsView.tsx @@ -30,13 +30,45 @@ export function DrumsView() { if (drumMachine) drumMachine.setSaturation(v) } + const randomizeTechno = () => { + // Haptic Feedback for randomization + if (window.Telegram?.WebApp?.HapticFeedback) { + window.Telegram.WebApp.HapticFeedback.impactOccurred('medium') + } + + // Apply Techno Foundation defaults + updateDrum('kick', { steps: 16, pulses: 4, rotate: 0, probability: 1.0 }) + updateDrum('snare', { steps: 16, pulses: 4, rotate: 4, probability: 1.0 }) + updateDrum('hihat', { steps: 16, pulses: 12, rotate: 0, probability: 1.0 }) + updateDrum('hihatOpen', { steps: 16, pulses: 4, rotate: 2, probability: 1.0 }) + updateDrum('clap', { steps: 16, pulses: 2, rotate: 4, probability: 1.0 }) + updateDrum('cowbell', { steps: 16, pulses: 3, rotate: 2, probability: 0.8 }) + } + return (
-

Настройки

+
+

Настройки

+ +
setVolume(d.id === 'cowbell' ? 'cow' : d.id, v)} + onChange={(v) => setVolume(d.id === 'cowbell' ? 'cowbell' : d.id, v)} size={40} />
diff --git a/src/components/MixerView.tsx b/src/components/MixerView.tsx index cadf2b00..8236d21c 100644 --- a/src/components/MixerView.tsx +++ b/src/components/MixerView.tsx @@ -58,10 +58,10 @@ export function MixerView() { size={48} /> setVolume('cow', v)} + onChange={(v) => setVolume('cowbell', v)} size={48} /> > 14) ^ (state >> 13)) & 1; + state = ((state << 1) | bit) & 0x7FFF; + data[i] = (state & 1) ? 1 : -1; + } + return buffer; +} diff --git a/src/logic/drums/TR808Clap.ts b/src/logic/drums/TR808Clap.ts index 3334a6fe..6b8b19af 100644 --- a/src/logic/drums/TR808Clap.ts +++ b/src/logic/drums/TR808Clap.ts @@ -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"); @@ -27,8 +29,8 @@ 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 @@ -36,7 +38,7 @@ export class TR808Clap { 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); diff --git a/src/logic/drums/TR808Cowbell.ts b/src/logic/drums/TR808Cowbell.ts index 3dd1d4fd..0a94afdc 100644 --- a/src/logic/drums/TR808Cowbell.ts +++ b/src/logic/drums/TR808Cowbell.ts @@ -2,11 +2,13 @@ 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) { } 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 @@ -15,6 +17,8 @@ export class TR808Cowbell { const osc1 = new Tone.Oscillator(freq1, "square"); const osc2 = new Tone.Oscillator(freq2, "square"); + osc1.phase = Math.random() * 360; + osc2.phase = Math.random() * 360; const mixGain = new Tone.Gain(0.5); const bpf = new Tone.Filter(applyVariance(800, 0.02), "bandpass"); @@ -30,10 +34,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); @@ -45,15 +50,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(); } } diff --git a/src/logic/drums/TR808HiHat.ts b/src/logic/drums/TR808HiHat.ts index acd1216d..077654c5 100644 --- a/src/logic/drums/TR808HiHat.ts +++ b/src/logic/drums/TR808HiHat.ts @@ -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 = 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"); @@ -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, 2.0); // +/- 2 cents const osc = new Tone.Oscillator(driftedFreq, "square"); osc.phase = Math.random() * 360; osc.connect(mixGain); @@ -28,7 +30,6 @@ export class TR808HiHat { }); // Routing Graph - // Oscillators -> MixGain -> [BPF1, BPF2] (Parallel) -> EnvGain -> HPF -> Destination mixGain.connect(bpf1); mixGain.connect(bpf2); bpf1.connect(envGain); @@ -48,18 +49,18 @@ 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 oscillators[0].onstop = () => { oscillators.forEach(o => o.dispose()); mixGain.dispose(); @@ -67,15 +68,16 @@ export class TR808HiHat { 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); + voice.oscillators.forEach(osc => osc.stop(time + 0.02)); }); - this.activeGains.clear(); + this.activeVoices.clear(); } } diff --git a/src/logic/drums/TR808Kick.ts b/src/logic/drums/TR808Kick.ts index 461f73fd..49433b21 100644 --- a/src/logic/drums/TR808Kick.ts +++ b/src/logic/drums/TR808Kick.ts @@ -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 @@ -19,22 +21,31 @@ export class TR808Kick { masterGain.connect(this.destination); // Micro-randomization using shared utilities - const tuneDrift = applyPitchDrift(tune, 1.0); + const tuneDrift = applyPitchDrift(tune, 1.0); // +/- 1 cent 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: + // 1. Rapid drop (membrane hit 'tonk') from startFreq to midFreq in 20ms + // 2. Slower drop to final tune in another 30ms (total 50ms sweep) const startFreq = tuneDrift * 2.5; + const midFreq = tuneDrift * 1.2; const endFreq = tuneDrift; osc.frequency.setValueAtTime(startFreq, time); + osc.frequency.exponentialRampToValueAtTime(midFreq, time + 0.02); osc.frequency.exponentialRampToValueAtTime(endFreq, time + 0.05); - // VCA Amp Envelope: Instant attack, adjustable exponential decay + // Two-stage VCA Amp Envelope: + // 1. Fast damping (20ms) to 50% volume (emulating diode damping) + // 2. Long exponential decay to zero + const dampingTime = 0.02; + const safeFinalDecay = Math.max(dampingTime + 0.01, finalDecay); + masterGain.gain.setValueAtTime(velocity, time); - masterGain.gain.exponentialRampToValueAtTime(0.001, time + finalDecay); + masterGain.gain.exponentialRampToValueAtTime(velocity * 0.5, time + dampingTime); + masterGain.gain.exponentialRampToValueAtTime(0.001, time + safeFinalDecay); - osc.start(time).stop(time + finalDecay); + osc.start(time).stop(time + safeFinalDecay); osc.onstop = () => { osc.dispose(); diff --git a/src/logic/drums/TR808Snare.ts b/src/logic/drums/TR808Snare.ts index 23f8f2ac..bd0e5011 100644 --- a/src/logic/drums/TR808Snare.ts +++ b/src/logic/drums/TR808Snare.ts @@ -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; @@ -40,10 +42,10 @@ 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); + gainLow.gain.setValueAtTime(Math.max(0.001, velocity * (1 - toneBalance)), time); gainLow.gain.exponentialRampToValueAtTime(0.001, time + vcaDecay); - gainHigh.gain.setValueAtTime(velocity * toneBalance, time); + gainHigh.gain.setValueAtTime(Math.max(0.001, velocity * toneBalance), time); gainHigh.gain.exponentialRampToValueAtTime(0.001, time + vcaDecay * 0.75); // Snappy Layer @@ -61,11 +63,13 @@ 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); oscHigh.start(time).stop(time + vcaDecay); + + // disposal is anchored to the noise source noiseSrc.start(time).stop(time + snappyDecay + 0.1); // Cleanup diff --git a/src/logic/drums/TR909Kick.ts b/src/logic/drums/TR909Kick.ts index dd2582ed..90f08b2b 100644 --- a/src/logic/drums/TR909Kick.ts +++ b/src/logic/drums/TR909Kick.ts @@ -19,6 +19,8 @@ export class TR909Kick { } trigger(time: number, pitch: number, decay: number, velocity: number = 0.8) { + if (velocity <= 0) return; + // 909 Kick base frequency is fixed around 50Hz const tune = 50; // The 'Pitch' parameter on 909 maps to the frequency sweep duration @@ -53,7 +55,7 @@ export class TR909Kick { bodyOsc.frequency.exponentialRampToValueAtTime(endFreq, time + sweepDuration); // VCA Envelope - bodyGain.gain.setValueAtTime(velocity, time); + bodyGain.gain.setValueAtTime(Math.max(0.001, velocity), time); bodyGain.gain.exponentialRampToValueAtTime(0.001, time + vcaDecay); // Click Layer (Noise) @@ -67,7 +69,7 @@ export class TR909Kick { // Ultra short envelope (10-20ms) for the click const clickDecay = applyVariance(0.02, 0.02); - noiseGain.gain.setValueAtTime(velocity * 0.7, time); + noiseGain.gain.setValueAtTime(Math.max(0.001, velocity * 0.7), time); noiseGain.gain.exponentialRampToValueAtTime(0.001, time + clickDecay); // Rectangular Pulse Click: Short 5ms impulse for attack articulation @@ -76,10 +78,12 @@ export class TR909Kick { pulseOsc.connect(pulseGain); pulseGain.connect(this.destination); - pulseGain.gain.setValueAtTime(velocity * 0.5, time); + pulseGain.gain.setValueAtTime(Math.max(0.001, velocity * 0.5), time); pulseGain.gain.exponentialRampToValueAtTime(0.001, time + 0.005); bodyOsc.start(time).stop(time + vcaDecay); + + // disposal is anchored to the noise source or body oscillator (longest) noiseSrc.start(time).stop(time + clickDecay); pulseOsc.start(time).stop(time + 0.005); diff --git a/src/logic/drums/TR909Snare.ts b/src/logic/drums/TR909Snare.ts index 4841f055..d103844d 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,17 +9,13 @@ 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 Snappy + this.noiseBuffer = generateLFSRNoise(Tone.getContext(), 0.5); } trigger(time: number, pitch: number, snappy: number, velocity: number = 0.8) { + if (velocity <= 0) return; + // 909 Snare Body: 2 triangle oscillators fixed at ~160Hz and ~220Hz const freq1 = 160; const freq2 = 220; @@ -36,6 +32,7 @@ export class TR909Snare { const osc2 = new Tone.Oscillator(toneDrift2 * 2, "triangle"); osc1.phase = Math.random() * 360; osc2.phase = Math.random() * 360; + // Routing with gain compensation to prevent clipping before the shaper const preShaperGain = new Tone.Gain(0.5); const bodyShaper = new Tone.WaveShaper(this.bodyCurve); @@ -66,6 +63,7 @@ export class TR909Snare { // Snappy Layer const noiseSrc = new Tone.BufferSource(this.noiseBuffer); const hpf = new Tone.Filter(noiseHPFFreq, "highpass"); // HPF to protect fundamental + // LPF controlled by 'Tone' (pitch parameter here), range 4kHz to 8kHz (research: toneCutoff) const toneCutoff = 4000 + pitch * 4000; const lpf = new Tone.Filter(applyVariance(toneCutoff, 0.02), "lowpass"); @@ -81,6 +79,8 @@ export class TR909Snare { osc1.start(time).stop(time + vcaDecay); osc2.start(time).stop(time + vcaDecay); + + // disposal is anchored to the noise source (the longest signal component) noiseSrc.start(time).stop(time + snappyDecay + 0.1); osc1.onstop = () => { diff --git a/src/store/audioStore.ts b/src/store/audioStore.ts index c90d17e8..19dd9e0b 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 @@ -22,7 +23,7 @@ export interface AudioState { hihat: number, hihatOpen: number, clap: number, - cow: number, + cowbell: number, pads: number } initialize: () => Promise @@ -30,7 +31,7 @@ export interface AudioState { setBpm: (bpm: number) => void setSwing: (swing: number) => void setCurrentStep: (step: number) => void - setVolume: (channel: 'bass' | 'lead' | 'kick' | 'snare' | 'hihat' | 'hihatOpen' | 'clap' | 'cow' | 'pads', value: number) => void + setVolume: (channel: 'bass' | 'lead' | 'kick' | 'snare' | 'hihat' | 'hihatOpen' | 'clap' | 'cowbell' | 'pads', value: number) => void } export const useAudioStore = create((set, get) => ({ @@ -43,7 +44,7 @@ export const useAudioStore = create((set, get) => ({ leadSynth: null, drumMachine: null, padSynth: null, - volumes: { bass: 0.8, lead: 0.8, kick: 0.8, snare: 0.8, hihat: 0.8, hihatOpen: 0.8, clap: 0.8, cow: 0.8, pads: 0.5 }, + volumes: { bass: 0.8, lead: 0.8, kick: 0.8, snare: 0.8, hihat: 0.8, hihatOpen: 0.8, clap: 0.8, cowbell: 0.8, pads: 0.5 }, initialize: async () => { if (get().isInitialized) return @@ -63,6 +64,27 @@ export const useAudioStore = create((set, get) => ({ Tone.Transport.bpm.value = get().bpm Tone.Transport.swing = get().swing + // Initial sync from instrument store + const drumStore = useDrumStore.getState() + drums.setKit(drumStore.kit) + drums.setSaturation(drumStore.drive) + + const instruments: ('kick' | 'snare' | 'hihat' | 'hihatOpen' | 'clap' | 'cowbell')[] = + ['kick', 'snare', 'hihat', 'hihatOpen', 'clap', 'cowbell'] + + instruments.forEach(id => { + drums.setDrumParams(id, drumStore[id].pitch, drumStore[id].decay) + }) + + // Sync initial volumes + const vols = get().volumes + drums.outputKick.gain.value = vols.kick + drums.outputSnare.gain.value = vols.snare + drums.outputHihat.gain.value = vols.hihat + drums.outputOpenHat.gain.value = vols.hihatOpen + drums.outputClap.gain.value = vols.clap + drums.outputCowbell.gain.value = vols.cowbell + set({ isInitialized: true, bassSynth: bassSynth, @@ -96,7 +118,7 @@ export const useAudioStore = create((set, get) => ({ if (channel === 'hihat') drumMachine.outputHihat.gain.value = value if (channel === 'hihatOpen') drumMachine.outputOpenHat.gain.value = value if (channel === 'clap') drumMachine.outputClap.gain.value = value - if (channel === 'cow') drumMachine.outputCowbell.gain.value = value + if (channel === 'cowbell') drumMachine.outputCowbell.gain.value = value } if (channel === 'pads' && padSynth) padSynth.synth.volume.value = Tone.gainToDb(value) diff --git a/src/store/instrumentStore.ts b/src/store/instrumentStore.ts index 9e96a9b2..360a29e2 100644 --- a/src/store/instrumentStore.ts +++ b/src/store/instrumentStore.ts @@ -44,12 +44,13 @@ interface DrumState { } export const useDrumStore = create((set) => ({ + // Techno Foundation: Kick (16/4), Snare (16/4, rotate 4), Hi-Hat (16/12) 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) => ({