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
30 changes: 27 additions & 3 deletions src/components/DrumsView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,19 @@ export function DrumsView() {
if (drumMachine) drumMachine.setSaturation(v)
}

const randomizeTechno = () => {
if (window.Telegram?.WebApp?.HapticFeedback) {
window.Telegram.WebApp.HapticFeedback.impactOccurred('rigid')
}

updateDrum('kick', { pulses: 4, rotate: 0, probability: 1.0 })
updateDrum('snare', { pulses: 4, rotate: 4, probability: 1.0 })
updateDrum('hihat', { pulses: 12, rotate: 0, probability: 1.0 })
updateDrum('hihatOpen', { pulses: 4, rotate: 2, probability: 1.0 })
updateDrum('clap', { pulses: 2, rotate: 4, probability: 1.0 })
updateDrum('cowbell', { pulses: 3, rotate: 2, probability: 0.8 })
}

return (
<div style={{ display: 'flex', flexDirection: 'column', gap: '24px' }}>
<TransportControls title="Драм-машина" />
Expand All @@ -38,6 +51,17 @@ 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={randomizeTechno}
style={{
width: '40px', height: '40px', borderRadius: '20px',
background: 'rgba(0,0,0,0.05)', border: 'none',
display: 'flex', alignItems: 'center', justifyContent: 'center'
}}
title="Randomize Techno"
>
<Dices size={20} />
</button>
<Knob
label="DRIVE"
value={drive}
Expand Down Expand Up @@ -105,9 +129,9 @@ export function DrumsView() {
/>
<Knob
label="Vol"
value={volumes[d.id === 'cowbell' ? 'cow' : d.id]}
value={volumes[d.id]}
min={0} max={1} step={0.01}
onChange={(v) => setVolume(d.id === 'cowbell' ? 'cow' : d.id, v)}
onChange={(v) => setVolume(d.id, v)}
size={40}
/>
</div>
Expand All @@ -122,7 +146,7 @@ export function DrumsView() {
{ name: 'HIHAT', data: hihat },
{ name: 'OPEN', data: hihatOpen },
{ name: 'CLAP', data: clap },
{ name: 'COW', data: useDrumStore((state) => state.cowbell) }
{ name: 'COWBELL', data: useDrumStore((state) => state.cowbell) }
].map((d, idx) => {
const pattern = rotateArray(bjorklund(d.data.steps, d.data.pulses), d.data.rotate)
return (
Expand Down
6 changes: 3 additions & 3 deletions src/components/MixerView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -58,10 +58,10 @@ export function MixerView() {
size={48}
/>
<Knob
label="Cow"
value={volumes.cow}
label="Cowbell"
value={volumes.cowbell}
min={0} max={1} step={0.01}
onChange={(v) => setVolume('cow', v)}
onChange={(v) => setVolume('cowbell', v)}
size={48}
/>
<Knob
Expand Down
5 changes: 5 additions & 0 deletions src/components/SequencerLoop.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,11 @@ export function SequencerLoop() {
const currentHarmony = useHarmonyStore.getState()
const currentPads = usePadStore.getState()

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

// 1. Drums (Euclidean - using cached patterns)
const patterns = drumPatternsRef.current
const currentDrums = useDrumStore.getState()
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 }
}

/**
* Bulk sync of kit, saturation and drum parameters from application state.
*/
syncInternalParams(kit: '808' | '909', saturation: number, params: Record<string, { pitch: number, decay: number }>) {
this.setKit(kit);
this.setSaturation(saturation);
Object.entries(params).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 @@ -20,6 +20,31 @@ export function makeDistortionCurve(amount: number = 20): Float32Array {
return curve;
}

/**
* Generates an AudioBuffer containing authentic 15-bit LFSR pseudo-random noise.
* Based on TR-909 digital noise specs.
*/
export function generateLFSRNoise(context: any, duration: number = 0.5): AudioBuffer {
const sampleRate = context.sampleRate;
const bufferSize = sampleRate * duration;
const buffer = context.createBuffer(1, bufferSize, sampleRate);
const data = buffer.getChannelData(0);

// 15-bit LFSR: x^15 + x^14 + 1
let state = 0x7FFF; // Non-zero initial state

for (let i = 0; i < bufferSize; i++) {
// Simple LFSR step
const bit = ((state >> 0) ^ (state >> 1)) & 1;
state = (state >> 1) | (bit << 14);

// Normalize to [-1, 1]
data[i] = (state / 0x7FFF) * 2 - 1;
}

return buffer;
}

/**
* Applies micro-randomization to a base frequency (Pitch Drift).
* Typically +/- 1Hz as per research.
Expand Down
29 changes: 19 additions & 10 deletions src/logic/drums/TR808Kick.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,19 +22,28 @@ 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
const startFreq = tuneDrift * 2.5;
const endFreq = tuneDrift;
// Pitch Envelope: Two-stage frequency sweep for authentic 'tonk'
// 1. Rapid snap (5ms) from Tune*2.5 to Tune*1.25
// 2. Slower sweep (45ms) down to the fundamental Tune
const freqSnap = tuneDrift * 2.5;
const freqMid = tuneDrift * 1.25;
const freqEnd = tuneDrift;

osc.frequency.setValueAtTime(freqSnap, time);
osc.frequency.exponentialRampToValueAtTime(freqMid, time + 0.005);
osc.frequency.exponentialRampToValueAtTime(freqEnd, time + 0.05);

// VCA Amp Envelope: Two-stage decay to emulate diode damping
// 1. Initial rapid damping (20ms) down to 50% velocity
// 2. Natural exponential decay for the remainder
const dampingTime = 0.02;
const safeFinalDecay = Math.max(dampingTime + 0.01, finalDecay);

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

// VCA Amp Envelope: Instant attack, adjustable exponential decay
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();
Expand Down
18 changes: 8 additions & 10 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;
}
// Authentically generate 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) {
Expand Down Expand Up @@ -68,7 +62,11 @@ export class TR909Snare {
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");
const lpf = new Tone.Filter({
frequency: applyVariance(toneCutoff, 0.02),
type: "lowpass",
rolloff: -12
});
const noiseGain = new Tone.Gain(0);

noiseSrc.connect(hpf);
Expand Down
29 changes: 25 additions & 4 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 All @@ -22,15 +23,15 @@ export interface AudioState {
hihat: number,
hihatOpen: number,
clap: number,
cow: number,
cowbell: number,
pads: number
}
initialize: () => Promise<void>
togglePlay: () => void
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<AudioState>((set, get) => ({
Expand All @@ -43,7 +44,7 @@ export const useAudioStore = create<AudioState>((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
Expand All @@ -63,6 +64,26 @@ export const useAudioStore = create<AudioState>((set, get) => ({
Tone.Transport.bpm.value = get().bpm
Tone.Transport.swing = get().swing

// Synchronize initial state to the engine
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 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,
Expand Down Expand Up @@ -96,7 +117,7 @@ export const useAudioStore = create<AudioState>((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)
Expand Down
4 changes: 2 additions & 2 deletions src/store/instrumentStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,11 +45,11 @@ interface DrumState {

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) => ({
Expand Down