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

const randomizeTechno = () => {
// Haptic feedback via Telegram WebApp API if available
// @ts-ignore
if (window.Telegram?.WebApp?.HapticFeedback) {
// @ts-ignore
window.Telegram.WebApp.HapticFeedback.impactOccurred('medium')
}

// Standard techno patterns from research/memory
updateDrum('kick', { pulses: 4, rotate: 0, steps: 16, probability: 1.0 })
updateDrum('snare', { pulses: 4, rotate: 4, steps: 16, probability: 1.0 })
updateDrum('hihat', { pulses: 12, rotate: 0, steps: 16, probability: 1.0 })
updateDrum('hihatOpen', { pulses: 4, rotate: 2, steps: 16, probability: 1.0 })
updateDrum('clap', { pulses: 2, rotate: 4, steps: 16, probability: 1.0 })
updateDrum('cowbell', { pulses: 3, rotate: 2, steps: 16, probability: 0.8 })
}

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={randomizeTechno}
className="icon-button"
title="Randomize Techno"
style={{ background: 'none', border: 'none', cursor: 'pointer', display: 'flex', color: 'var(--tg-theme-button-color)' }}
>
<Dices size={18} />
</button>
</div>
<div style={{ display: 'flex', gap: '12px', alignItems: 'center' }}>
<Knob
label="DRIVE"
Expand Down Expand Up @@ -105,9 +132,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 Down
4 changes: 2 additions & 2 deletions src/components/MixerView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -59,9 +59,9 @@ export function MixerView() {
/>
<Knob
label="Cow"
value={volumes.cow}
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
25 changes: 25 additions & 0 deletions src/logic/DrumMachine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,31 @@ export class DrumMachine {
this.params[drum] = { pitch, decay }
}

/**
* Bulk synchronization of kit selection, saturation, individual drum parameters, and channel volumes.
* Ensures engine-UI parity upon launch or manual reset.
*/
syncInternalParams(
kit: '808' | '909',
drive: number,
drumParams: Record<string, { pitch: number, decay: number }>,
volumes: Record<string, number>
) {
this.setKit(kit)
this.setSaturation(drive)

for (const [drum, p] of Object.entries(drumParams)) {
this.setDrumParams(drum, p.pitch, p.decay)
}

if (volumes.kick !== undefined) this.outputKick.gain.value = volumes.kick
if (volumes.snare !== undefined) this.outputSnare.gain.value = volumes.snare
if (volumes.hihat !== undefined) this.outputHihat.gain.value = volumes.hihat
if (volumes.hihatOpen !== undefined) this.outputOpenHat.gain.value = volumes.hihatOpen
if (volumes.clap !== undefined) this.outputClap.gain.value = volumes.clap
if (volumes.cowbell !== undefined) this.outputCowbell.gain.value = volumes.cowbell
}

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
23 changes: 23 additions & 0 deletions src/logic/DrumUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,3 +40,26 @@ 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 authentic 15-bit LFSR (Linear Feedback Shift Register) noise buffer.
* Used in TR-909 snare to provide characteristic "digital" texture.
* @param audioCtx - AudioContext to create the buffer
* @param duration - Duration in seconds
*/
export function generateLFSRNoise(audioCtx: BaseAudioContext, duration: number): AudioBuffer {
const bufferSize = Math.floor(audioCtx.sampleRate * duration);
const buffer = audioCtx.createBuffer(1, bufferSize, audioCtx.sampleRate);
const data = buffer.getChannelData(0);

let lfsr = 0x7FFF; // 15-bit seed

for (let i = 0; i < bufferSize; i++) {
// Feedback for 15-bit LFSR (taps at 15 and 14)
const bit = ((lfsr >> 0) ^ (lfsr >> 1)) & 1;
lfsr = (lfsr >> 1) | (bit << 14);
data[i] = bit * 2 - 1;
}

return buffer;
}
13 changes: 10 additions & 3 deletions src/logic/drums/TR808Kick.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,11 +30,18 @@ export class TR808Kick {
osc.frequency.setValueAtTime(startFreq, time);
osc.frequency.exponentialRampToValueAtTime(endFreq, time + 0.05);

// VCA Amp Envelope: Instant attack, adjustable exponential decay
// VCA Amp Envelope: Diode damping emulation
// Initial 20ms rapid decay to 50% volume for the punch
const punchDecay = 0.02;
masterGain.gain.setValueAtTime(velocity, time);
masterGain.gain.exponentialRampToValueAtTime(0.001, time + finalDecay);
masterGain.gain.exponentialRampToValueAtTime(velocity * 0.5, time + punchDecay);

osc.start(time).stop(time + finalDecay);
// Final exponential decay to zero
// Ensure finalDecay is always longer than punchDecay
const safeFinalDecay = Math.max(punchDecay + 0.01, finalDecay);
masterGain.gain.exponentialRampToValueAtTime(0.001, time + safeFinalDecay);

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

osc.onstop = () => {
osc.dispose();
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 Snare Snappy component
this.noiseBuffer = generateLFSRNoise(Tone.getContext().rawContext, 0.5);
}

trigger(time: number, pitch: number, snappy: number, velocity: number = 0.8) {
Expand Down
25 changes: 21 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,22 @@ export const useAudioStore = create<AudioState>((set, get) => ({
Tone.Transport.bpm.value = get().bpm
Tone.Transport.swing = get().swing

// Synchronize initial state from stores to internal engine params
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
},
get().volumes
)

set({
isInitialized: true,
bassSynth: bassSynth,
Expand Down Expand Up @@ -96,7 +113,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