import * as Tone from 'https://esm.sh/tone'; // Psychedelic EDM Synth Engine — Comprehensive Hand-Distance Control // Each finger's distance from palm is a continuous controller // Synth hand: melodic/tonal, Drum hand: percussive/textural export class MusicManager { constructor() { this.padSynths = new Map(); this.activePatterns = this.padSynths; this.reverb = null; this.delay = null; this.chorus = null; this.analyser = null; this.isStarted = false; this.handVolumes = new Map(); this.fingerCooldowns = new Map(); this.percCooldown = 0; this.FINGER_COOLDOWN_MS = 120; this.PERC_COOLDOWN_MS = 150; this.HAND_VELOCITY_THRESHOLD = 0.15; this.fingerIntervals = { index: 0, middle: 3, ring: 7, pinky: 10 }; this.scale = [ 'C1','Eb1','G1','Bb1','C2','Eb2','F2','G2','Bb2', 'C3','Eb3','F3','G3','Bb3','C4','Eb4','F4','G4','Bb4','C5','Eb5','F5' ]; this.padPresets = [ { name: 'Ambient Sub', oscillator: { type: 'sine' }, envelope: { attack: 0.8, decay: 0.6, sustain: 0.7, release: 1.0 } }, { name: 'Dub Growl', oscillator: { type: 'sawtooth' }, envelope: { attack: 0.3, decay: 0.5, sustain: 0.5, release: 0.8 } }, { name: 'Dream Wash', oscillator: { type: 'triangle' }, envelope: { attack: 1.0, decay: 0.8, sustain: 0.6, release: 1.2 } }, { name: 'Detuned Saw', oscillator: { type: 'fatsawtooth', spread: 40, count: 3 }, envelope: { attack: 0.6, decay: 0.5, sustain: 0.7, release: 1.0 } }, { name: 'Warm Square', oscillator: { type: 'fatsquare', spread: 20, count: 3 }, envelope: { attack: 0.5, decay: 0.4, sustain: 0.6, release: 0.8 } } ]; this.currentSynthIndex = 0; this._prevExtensions = {}; this._prevDrumExtensions = { thumb: 0, index: 0, middle: 0, ring: 0, pinky: 0 }; this._drumFingerCooldowns = { thumb: 0, index: 0, middle: 0, ring: 0, pinky: 0 }; this._smoothDist = { thumb: 0, index: 0, middle: 0, ring: 0, pinky: 0 }; this._smoothDrumDist = { thumb: 0, index: 0, middle: 0, ring: 0, pinky: 0 }; this._SMOOTH_ALPHA = 0.3; // balanced: responsive but not jittery this._frameCount = 0; // throttle heavy updates to every 3rd frame } // Resume AudioContext if browser suspended it (called on clicks + periodically) async ensureAudioActive() { if (Tone.context.state !== 'running') { console.log('AudioContext suspended, resuming...'); await Tone.context.resume(); await Tone.start(); } } async start() { if (this.isStarted) { // Already started — just make sure context is alive await this.ensureAudioActive(); return; } await Tone.start(); try { // === MASTER EFFECTS CHAIN === this.limiter = new Tone.Limiter(-3).toDestination(); this.reverb = new Tone.Reverb({ decay: 8, preDelay: 0.05, wet: 0.45 }).connect(this.limiter); this.delay = new Tone.PingPongDelay({ delayTime: '4n', feedback: 0.4, wet: 0.25 }).connect(this.reverb); this.chorus = new Tone.Chorus({ frequency: 2, delayTime: 4, depth: 0.7 }).connect(this.reverb); this.chorus.start(); this.filter = new Tone.Filter(16000, 'lowpass').connect(this.chorus); this.analyser = new Tone.Analyser('waveform', 1024); this.reverb.connect(this.analyser); // ===================================================================== // SYNTH HAND — Psychedelic EDM soundboard voices // Thumb+Index = volume (pinch). Middle/Ring/Pinky = the instruments. // ===================================================================== this.fingerSynths = {}; // === MIDDLE (ROOT): 303 Acid Squelch === // Sawtooth + high-resonance filter sweep. THE psychedelic sound. // Distance controls filter cutoff — close=dark, extended=screaming acid this._acidFilter = new Tone.Filter({ frequency: 400, type: 'lowpass', Q: 12, rolloff: -24 }).connect(this.delay); this.fingerSynths.middle = new Tone.MonoSynth({ oscillator: { type: 'sawtooth' }, filter: { Q: 12, type: 'lowpass', rolloff: -24 }, envelope: { attack: 0.005, decay: 0.2, sustain: 0.3, release: 0.3 }, filterEnvelope: { attack: 0.001, decay: 0.2, sustain: 0.1, release: 0.3, baseFrequency: 200, octaves: 4, exponent: 2 } }).connect(this._acidFilter); this.fingerSynths.middle.volume.value = -4; // === RING (5th): Goa Pluck === // Square wave, fast decay, no sustain — futuristic harp. // Drowned in ping-pong delay so a single tap echoes across stereo field. this._shimmerReverb = new Tone.Reverb({ decay: 4, preDelay: 0.02, wet: 0.5 }).connect(this.limiter); this._pluckDelay = new Tone.PingPongDelay({ delayTime: '16n', feedback: 0.45, wet: 0.4 }).connect(this._shimmerReverb); this.fingerSynths.ring = new Tone.Synth({ oscillator: { type: 'square' }, envelope: { attack: 0.001, decay: 0.12, sustain: 0, release: 0.08 } }).connect(this._pluckDelay); this.fingerSynths.ring.volume.value = -6; // === PINKY (m7): FM Laser Zap === // Sine + pitch envelope + FM for metallic "pew-pew" sci-fi laser. // Distance controls FM depth — close=subtle, extended=full laser this.distortion = new Tone.Distortion({ distortion: 0, wet: 0 }).connect(this.limiter); this.fingerSynths.pinky = new Tone.FMSynth({ harmonicity: 8, modulationIndex: 20, oscillator: { type: 'sine' }, envelope: { attack: 0.001, decay: 0.15, sustain: 0, release: 0.1 }, modulation: { type: 'square' }, modulationEnvelope: { attack: 0.001, decay: 0.1, sustain: 0, release: 0.05 } }).connect(this.distortion); this.fingerSynths.pinky.volume.value = -2; // === CONTINUOUS: Acid squelch drone (always-on, distance = filter) === this.middleContinuous = new Tone.Synth({ oscillator: { type: 'sawtooth' }, envelope: { attack: 0.4, decay: 0, sustain: 1, release: 0.6 } }).connect(this._acidFilter); this.middleContinuous.volume.value = -24; // === CONTINUOUS: Ring pluck shimmer drone === this.ringContinuous = new Tone.Synth({ oscillator: { type: 'triangle' }, envelope: { attack: 0.8, decay: 0, sustain: 1, release: 1.0 } }).connect(this._shimmerReverb); this.ringContinuous.volume.value = -30; // === CONTINUOUS: Pinky sub-wobble drone === this.pinkyContinuous = new Tone.Synth({ oscillator: { type: 'sine' }, envelope: { attack: 0.5, decay: 0, sustain: 1, release: 0.8 } }).connect(this.distortion); this.pinkyContinuous.volume.value = -30; // ===================================================================== // DRUM HAND — Real drum samples, finger distance = repeat rate // ===================================================================== this.drumFingerSynths = {}; // Drum sample players — kick, hihat, clap this.drumPlayers = new Tone.Players({ urls: { kick: 'assets/kick.wav', hihat: 'assets/hihat.wav', clap: 'assets/clap.wav' }, onload: () => { console.log('Drum samples loaded'); this._drumPlayersLoaded = true; this.drumPlayers.player('kick').volume.value = 0; this.drumPlayers.player('hihat').volume.value = -2; this.drumPlayers.player('clap').volume.value = 0; } }).connect(this.limiter); this._drumPlayersLoaded = false; // THUMB: Glitch zap trigger this.drumThumbSynth = new Tone.FMSynth({ harmonicity: 6, modulationIndex: 15, oscillator: { type: 'square' }, envelope: { attack: 0.001, decay: 0.05, sustain: 0, release: 0.03 }, modulation: { type: 'sawtooth' }, modulationEnvelope: { attack: 0.001, decay: 0.03, sustain: 0, release: 0.02 } }).connect(this.delay); this.drumThumbSynth.volume.value = -8; // Beat timers this._drumBeatTimers = { kick: 0, hihat: 0, clap: 0 }; // === DEDICATED TRIGGER SYNTHS (loud, immediate, per-finger) === // These fire on open/close transitions — separate from the BPM sample loop // Kick trigger: punchy membrane, louder than sample this._triggerKick = new Tone.MembraneSynth({ pitchDecay: 0.06, octaves: 6, oscillator: { type: 'sine' }, envelope: { attack: 0.001, decay: 0.3, sustain: 0, release: 0.2 } }).connect(this.limiter); this._triggerKick.volume.value = 2; // Hihat trigger: bright noise burst this._triggerHat = new Tone.NoiseSynth({ noise: { type: 'white' }, envelope: { attack: 0.001, decay: 0.05, sustain: 0, release: 0.03 } }).connect(this.limiter); this._triggerHat.volume.value = -2; // Clap trigger: layered noise with bandpass this._clapFilter = new Tone.Filter({ frequency: 1500, type: 'bandpass', Q: 2 }).connect(this.reverb); this._triggerClap = new Tone.NoiseSynth({ noise: { type: 'pink' }, envelope: { attack: 0.001, decay: 0.1, sustain: 0, release: 0.06 } }).connect(this._clapFilter); this._triggerClap.volume.value = 0; // Legacy references this.kickSynth = new Tone.MembraneSynth({ pitchDecay: 0.08, octaves: 8, oscillator: { type: 'sine' }, envelope: { attack: 0.001, decay: 0.4, sustain: 0, release: 0.3 } }).connect(this.limiter); this.kickSynth.volume.value = -4; this.hatSynth = new Tone.NoiseSynth({ noise: { type: 'white' }, envelope: { attack: 0.001, decay: 0.06, sustain: 0, release: 0.02 } }).connect(this.limiter); this.hatSynth.volume.value = -8; this.pluckSynth = { releaseAll: () => {} }; // === SUB-BASS === this.subBass = new Tone.Synth({ oscillator: { type: 'sine' }, envelope: { attack: 0.3, decay: 0, sustain: 1, release: 0.8 } }).connect(this.limiter); this.subBass.volume.value = -18; this.wobbleLFO = new Tone.LFO({ frequency: 0.3, min: -22, max: -12 }); this.wobbleLFO.connect(this.subBass.volume); this.wobbleLFO.start(); this._continuousActive = false; // === SHAPE BASS — epic deep bass tied to the white quadrilateral === // Z-depth modulates lowpass filter + distortion (closer = darker/heavier) this._shapeBassFilter = new Tone.Filter({ frequency: 800, type: 'lowpass', Q: 2, rolloff: -24 }).connect(this.limiter); this._shapeBassDistortion = new Tone.Distortion({ distortion: 0, wet: 0 }).connect(this._shapeBassFilter); this._shapeBass = new Tone.Synth({ oscillator: { type: 'fatsawtooth', spread: 15, count: 3 }, envelope: { attack: 0.3, decay: 0, sustain: 1, release: 1.5 } }).connect(this._shapeBassDistortion); this._shapeBass.volume.value = -10; this._shapeBassActive = false; this._shapeSubBass = new Tone.Synth({ oscillator: { type: 'sine' }, envelope: { attack: 0.5, decay: 0, sustain: 1, release: 1.0 } }).connect(this.limiter); this._shapeSubBass.volume.value = -8; // Pre-allocate touch synths (no dynamic allocation = no memory leak) this._initTouchSynths(); } catch(e) { console.error('MusicManager start() error:', e); } this.isStarted = true; console.log('Psychedelic EDM engine v2 ready — distance-from-palm control'); } _smooth(target, key, raw) { target[key] += (raw - target[key]) * this._SMOOTH_ALPHA; return target[key]; } // --- Pad Management --- startArpeggio(handId, rootNote, playerIndex) { if (!this.isStarted || this.padSynths.has(handId) || this._panicMuted) return; // Per-player preset: player 2 uses a different timbre const pIdx = playerIndex || 0; const presetIdx = (this.currentSynthIndex + pIdx * 2) % this.padPresets.length; const preset = this.padPresets[presetIdx]; // Per-player key offset: player 2 transposed up a perfect 4th (5 semitones) const keyOffset = (pIdx === 0) ? 1 : Math.pow(2, 5/12); // P4 for player 2 // Auto-chord: root + minor 3rd + perfect 5th const makePad = (volDb) => { const s = new Tone.Synth({ oscillator: { ...preset.oscillator }, envelope: { ...preset.envelope } }); s.connect(this.filter); s.volume.value = volDb; return s; }; const rootPad = makePad(-10); const thirdPad = makePad(-14); // minor 3rd, slightly quieter const fifthPad = makePad(-13); // perfect 5th const freq = Tone.Frequency(rootNote).toFrequency() * keyOffset; rootPad.triggerAttack(freq, Tone.now()); thirdPad.triggerAttack(freq * Math.pow(2, 3/12), Tone.now()); // minor 3rd fifthPad.triggerAttack(freq * Math.pow(2, 7/12), Tone.now()); // perfect 5th if (this.subBass) this.subBass.triggerAttack(freq / 2, Tone.now()); this.padSynths.set(handId, { synth: rootPad, thirdSynth: thirdPad, fifthSynth: fifthPad, currentRoot: rootNote, keyOffset, playerIndex: pIdx }); this.handVolumes.set(handId, 0.2); this.fingerCooldowns.set(handId, { thumb: 0, index: 0, middle: 0, ring: 0, pinky: 0 }); // Start continuous synths if (!this._continuousActive) { this._continuousActive = true; try { this.middleContinuous.triggerAttack(freq * 1.498, Tone.now()); this.ringContinuous.triggerAttack(freq * 2, Tone.now()); this.pinkyContinuous.triggerAttack(freq / 2, Tone.now()); } catch(e) {} } } updateArpeggio(handId, newRootNote) { const padData = this.padSynths.get(handId); if (!padData || padData.currentRoot === newRootNote) return; const keyOffset = padData.keyOffset || 1; const freq = Tone.Frequency(newRootNote).toFrequency() * keyOffset; padData.synth.frequency.rampTo(freq, 0.15); if (padData.thirdSynth) padData.thirdSynth.frequency.rampTo(freq * Math.pow(2, 3/12), 0.15); if (padData.fifthSynth) padData.fifthSynth.frequency.rampTo(freq * Math.pow(2, 7/12), 0.15); if (this.subBass) this.subBass.frequency.rampTo(freq / 2, 0.2); try { if (this.middleContinuous) this.middleContinuous.frequency.rampTo(freq * Math.pow(2, 3/12), 0.2); if (this.ringContinuous) this.ringContinuous.frequency.rampTo(freq * 2, 0.2); if (this.pinkyContinuous) this.pinkyContinuous.frequency.rampTo(freq / 2, 0.2); } catch(e) {} padData.currentRoot = newRootNote; } updateArpeggioVolume(handId, velocity) { const padData = this.padSynths.get(handId); if (!padData) return; const clamped = Math.max(0, Math.min(1, velocity)); this.handVolumes.set(handId, clamped); const db = -30 + clamped * 26; padData.synth.volume.rampTo(db, 0.03); if (padData.thirdSynth) padData.thirdSynth.volume.rampTo(db - 4, 0.03); if (padData.fifthSynth) padData.fifthSynth.volume.rampTo(db - 3, 0.03); } stopArpeggio(handId) { const padData = this.padSynths.get(handId); if (padData) { padData.synth.triggerRelease(Tone.now()); if (padData.thirdSynth) padData.thirdSynth.triggerRelease(Tone.now()); if (padData.fifthSynth) padData.fifthSynth.triggerRelease(Tone.now()); if (this.padSynths.size <= 1 && this.subBass) { this.subBass.triggerRelease(Tone.now()); } if (this.padSynths.size <= 1) { this._continuousActive = false; try { this.middleContinuous.triggerRelease(Tone.now()); } catch(e) {} try { this.ringContinuous.triggerRelease(Tone.now()); } catch(e) {} try { this.pinkyContinuous.triggerRelease(Tone.now()); } catch(e) {} } if (!this._pendingDisposals) this._pendingDisposals = new Set(); const synths = [padData.synth, padData.thirdSynth, padData.fifthSynth].filter(Boolean); // Force-dispose oldest if too many pending (prevents memory leak on hand flicker) if (this._pendingDisposals.size > 8) { const oldest = this._pendingDisposals.values().next().value; try { oldest.dispose(); } catch(e) {} this._pendingDisposals.delete(oldest); } for (const synth of synths) { if (!this._pendingDisposals.has(synth)) { this._pendingDisposals.add(synth); // Disconnect immediately to stop audio, dispose after release tail try { synth.disconnect(); } catch(e) {} setTimeout(() => { try { synth.dispose(); } catch(e) {} this._pendingDisposals.delete(synth); }, 1000); } } this.padSynths.delete(handId); this.handVolumes.delete(handId); this.fingerCooldowns.delete(handId); } } // ===================================================================== // SYNTH HAND (hand 0) // Thumb+Index = volume only. Middle/Ring/Pinky = continuous + triggers // Each finger sounds RADICALLY different via separate signal chains // ===================================================================== updateGesture(handId, gestureData) { if (!this.isStarted || this._panicMuted) return; const { fingerStates, handVelocity, rootNote } = gestureData; const now = performance.now(); const cooldowns = this.fingerCooldowns.get(handId); if (!cooldowns) return; if (!this._prevExtensions[handId]) { this._prevExtensions[handId] = { thumb: 0, index: 0, middle: 0, ring: 0, pinky: 0 }; } const prevExt = this._prevExtensions[handId]; const dist = gestureData.fingerDistances || gestureData.fingerExtensions || { thumb: 0.1, index: 0.1, middle: 0.1, ring: 0.1, pinky: 0.1 }; const d = {}; for (const f of ['thumb', 'index', 'middle', 'ring', 'pinky']) { d[f] = this._smooth(this._smoothDist, f, dist[f] || 0); } const rootFreq = Tone.Frequency(rootNote).toFrequency(); // INDEX: No effect (square/volume finger — fully independent) const doHeavyUpdate = true; // no throttling — responsiveness > CPU savings // === MIDDLE (ROOT): 303 Acid Squelch === // Distance = filter cutoff. Close=dark rumble, extended=screaming acid. if (this._acidFilter && doHeavyUpdate) { this._acidFilter.frequency.value = 200 + Math.pow(d.middle, 2) * 10000; this._acidFilter.Q.value = 6 + d.middle * 12; } if (this.fingerSynths.middle) { if (prevExt.middle < 0.2 && d.middle > 0.35 && (now - cooldowns.middle) > this.FINGER_COOLDOWN_MS) { cooldowns.middle = now; this.fingerSynths.middle.triggerAttackRelease( Tone.Frequency(rootFreq).toNote(), '4n', Tone.now(), 0.9 ); } } if (this.middleContinuous && doHeavyUpdate) { this.middleContinuous.volume.rampTo(d.middle < 0.1 ? -Infinity : -36 + d.middle * 22, 0.03); } // === RING (5th): Goa Pluck === // Distance = delay feedback + reverb wet. Close=dry tap, extended=echoing cascade if (this.fingerSynths.ring) { if (prevExt.ring < 0.2 && d.ring > 0.35 && (now - cooldowns.ring) > this.FINGER_COOLDOWN_MS) { cooldowns.ring = now; const pluckFreq = rootFreq * Math.pow(2, 7/12); // perfect 5th this.fingerSynths.ring.triggerAttackRelease( Tone.Frequency(pluckFreq).toNote(), '16n', Tone.now(), 0.7 + d.ring * 0.3 ); } } if (doHeavyUpdate) { if (this._pluckDelay) { this._pluckDelay.feedback.value = 0.2 + d.ring * 0.45; this._pluckDelay.wet.value = 0.1 + d.ring * 0.5; } if (this._shimmerReverb) { this._shimmerReverb.wet.value = 0.2 + d.ring * 0.6; } if (this.ringContinuous) { this.ringContinuous.volume.rampTo(d.ring < 0.1 ? -Infinity : -40 + d.ring * 22, 0.03); } } // === PINKY (m7): FM Laser Zap === // Distance = FM depth + distortion. Close=subtle ping, extended=full laser blast if (this.fingerSynths.pinky && doHeavyUpdate) { try { this.fingerSynths.pinky.modulationIndex.value = 2 + Math.pow(d.pinky, 1.5) * 35; this.fingerSynths.pinky.harmonicity.value = 4 + d.pinky * 8; } catch(e) {} if (prevExt.pinky < 0.2 && d.pinky > 0.35 && (now - cooldowns.pinky) > this.FINGER_COOLDOWN_MS) { cooldowns.pinky = now; const zapFreq = rootFreq * Math.pow(2, 10/12); // minor 7th this.fingerSynths.pinky.triggerAttackRelease( Tone.Frequency(zapFreq).toNote(), '8n', Tone.now(), 0.9 ); } } if (doHeavyUpdate) { if (this.distortion) { this.distortion.distortion = Math.pow(d.pinky, 1.5) * 0.6; this.distortion.wet.value = d.pinky * 0.5; } if (this.pinkyContinuous) { this.pinkyContinuous.volume.rampTo(d.pinky < 0.1 ? -Infinity : -40 + d.pinky * 22, 0.03); } } // === WRIST ANGLE: Massive tonal modulation === { // -1 = tilted left, 0 = straight up, +1 = tilted right // Controls: master filter cutoff, pad detune, delay time, chorus speed const wristAngle = gestureData.wristAngle || 0; const absAngle = Math.abs(wristAngle); // Master filter: straight up = bright (16kHz), tilted = dark (200Hz) if (this.filter) { const filterCutoff = 16000 * Math.pow(0.02, absAngle); // 16kHz → ~300Hz this.filter.frequency.rampTo(filterCutoff, 0.03); } // Pad detune: tilted = detuned/dissonant, up to ±100 cents this.padSynths.forEach(padData => { padData.synth.detune.rampTo(wristAngle * 100, 0.03); if (padData.thirdSynth) padData.thirdSynth.detune.rampTo(-wristAngle * 60, 0.03); if (padData.fifthSynth) padData.fifthSynth.detune.rampTo(wristAngle * 40, 0.03); }); // Delay time shift: tilted right = longer delay, left = shorter if (this.delay) { const baseDelay = 0.2; // ~8th note const delayShift = baseDelay + wristAngle * 0.15; // 0.05 → 0.35 try { this.delay.delayTime.rampTo(Math.max(0.01, delayShift), 0.03); } catch(e) {} } // Chorus speed: more tilt = faster chorus wobble if (this.chorus) { this.chorus.frequency.value = 1 + absAngle * 8; // 1→9 Hz } // Wobble LFO speed: angle adds to wobble if (this.wobbleLFO) { this.wobbleLFO.frequency.value = 0.3 + absAngle * 6; } // FM continuous: wrist angle adds extra modulation depth if (this.middleContinuous) { try { const angleModBoost = absAngle * 10; this.middleContinuous.harmonicity.value = 2 + absAngle * 4; } catch(e) {} } // === Hand spread → chorus depth (additive with wrist angle) === const spread = gestureData.handSpread || 0; if (this.chorus) { this.chorus.depth = 0.3 + spread * 0.7; } // === DELAY: Controlled by ring (shimmer) === if (this.delay) { this.delay.feedback.value = 0.1 + d.ring * 0.55; this.delay.wet.value = 0.05 + d.ring * 0.4; } // === REVERB: Controlled by combined ring+pinky === if (this.reverb) { this.reverb.wet.value = 0.15 + Math.max(d.ring, d.pinky) * 0.5; } } // end doHeavyUpdate wrist angle block // Store for next frame for (const f of ['thumb', 'index', 'middle', 'ring', 'pinky']) { prevExt[f] = d[f]; } // Percussive hits on sharp hand movement if ((now - this.percCooldown) > this.PERC_COOLDOWN_MS) { const vx = handVelocity?.x || 0; const vy = handVelocity?.y || 0; const magnitude = Math.sqrt(vx * vx + vy * vy); if (magnitude > this.HAND_VELOCITY_THRESHOLD) { this.percCooldown = now; if (vy > this.HAND_VELOCITY_THRESHOLD) { this.kickSynth.triggerAttackRelease('C1', '8n', Tone.now(), Math.min(1, vy * 3)); } else if (Math.abs(vx) > this.HAND_VELOCITY_THRESHOLD) { this.hatSynth.triggerAttackRelease('16n', Tone.now(), Math.min(1, Math.abs(vx) * 3)); } } } } // ===================================================================== // DRUM HAND (hand 1) // Fingers held up = drum pattern active (via DrumManager labels) // PLUS: continuous distance-based synth percussion from MusicManager // Key fix: drum middle is now always-on when extended (not just triggers) // ===================================================================== updateDrumGesture(gestureData) { if (!this.isStarted || this._panicMuted) return; const now = performance.now(); const prevExt = this._prevDrumExtensions; const cooldowns = this._drumFingerCooldowns; const dist = gestureData.fingerDistances || gestureData.fingerExtensions || { thumb: 0.1, index: 0.1, middle: 0.1, ring: 0.1, pinky: 0.1 }; const d = {}; for (const f of ['thumb', 'index', 'middle', 'ring', 'pinky']) { d[f] = this._smooth(this._smoothDrumDist, f, dist[f] || 0); } // ===================================================================== // IMMEDIATE TRIGGERS — loud distinct sound on every finger open/close // Synth hits (not just samples) so each finger is unmistakable // ===================================================================== const OPEN_THRESH = 0.22; const CLOSE_THRESH = 0.12; // MIDDLE: Deep boom on open, short thud on close if (prevExt.middle < OPEN_THRESH && d.middle >= OPEN_THRESH) { try { this._triggerKick.triggerAttackRelease('C1', '8n', Tone.now(), 1.0); if (this._drumPlayersLoaded) this.drumPlayers.player('kick').start(Tone.now()); } catch(e) {} } else if (prevExt.middle >= OPEN_THRESH && d.middle < CLOSE_THRESH) { try { this._triggerKick.triggerAttackRelease('G1', '32n', Tone.now(), 0.5); } catch(e) {} } // RING: Bright tick on open, soft click on close if (prevExt.ring < OPEN_THRESH && d.ring >= OPEN_THRESH) { try { this._triggerHat.triggerAttackRelease('16n', Tone.now(), 1.0); if (this._drumPlayersLoaded) this.drumPlayers.player('hihat').start(Tone.now()); } catch(e) {} } else if (prevExt.ring >= OPEN_THRESH && d.ring < CLOSE_THRESH) { try { this._triggerHat.triggerAttackRelease('64n', Tone.now(), 0.4); } catch(e) {} } // PINKY: Sharp clap on open, soft snap on close if (prevExt.pinky < OPEN_THRESH && d.pinky >= OPEN_THRESH) { try { this._triggerClap.triggerAttackRelease('8n', Tone.now(), 1.0); if (this._drumPlayersLoaded) this.drumPlayers.player('clap').start(Tone.now()); } catch(e) {} } else if (prevExt.pinky >= OPEN_THRESH && d.pinky < CLOSE_THRESH) { try { this._triggerClap.triggerAttackRelease('32n', Tone.now(), 0.4); } catch(e) {} } // THUMB: Zap on open, reverse zap on close if (prevExt.thumb < OPEN_THRESH && d.thumb >= OPEN_THRESH) { const zapNote = ['C4','Eb4','G4','Bb4'][Math.floor(Math.random() * 4)]; try { this.drumThumbSynth.triggerAttackRelease(zapNote, '32n', Tone.now(), 0.9); } catch(e) {} } else if (prevExt.thumb >= OPEN_THRESH && d.thumb < CLOSE_THRESH) { try { this.drumThumbSynth.triggerAttackRelease('C3', '64n', Tone.now(), 0.5); } catch(e) {} } // ===================================================================== // BPM LOOP — plays while finger is held open (secondary layer) // Middle=KICK, Ring=HIHAT, Pinky=CLAP // ===================================================================== const DEAD_ZONE = 0.25; // higher than trigger thresh so loop starts after open const MIN_BPM = 20; // very slow ambient pulse const MAX_BPM = 100; // chill downtempo cap const distToInterval = (dist) => { if (dist < DEAD_ZONE) return Infinity; const t = (dist - DEAD_ZONE) / (1 - DEAD_ZONE); const bpm = MIN_BPM + Math.pow(t, 1.3) * (MAX_BPM - MIN_BPM); return 60000 / bpm; }; if (this._drumPlayersLoaded) { // --- MIDDLE: KICK --- const kickInterval = distToInterval(d.middle); if (kickInterval < Infinity && (now - this._drumBeatTimers.kick) > kickInterval) { this._drumBeatTimers.kick = now; try { this.drumPlayers.player('kick').start(Tone.now()); } catch(e) {} } // --- RING: HIHAT --- const hihatInterval = distToInterval(d.ring); if (hihatInterval < Infinity && (now - this._drumBeatTimers.hihat) > hihatInterval) { this._drumBeatTimers.hihat = now; try { this.drumPlayers.player('hihat').start(Tone.now()); } catch(e) {} } // --- PINKY: CLAP --- const clapInterval = distToInterval(d.pinky); if (clapInterval < Infinity && (now - this._drumBeatTimers.clap) > clapInterval) { this._drumBeatTimers.clap = now; try { this.drumPlayers.player('clap').start(Tone.now()); } catch(e) {} } } // (thumb trigger handled above in immediate triggers section) // === WRIST ANGLE: boosts drum volume when tilted === const drumWristAngle = gestureData.wristAngle || 0; const drumAbsAngle = Math.abs(drumWristAngle); if (this._drumPlayersLoaded) { try { this.drumPlayers.player('kick').volume.value = 0 + drumAbsAngle * 4; this.drumPlayers.player('hihat').volume.value = -2 + drumAbsAngle * 4; this.drumPlayers.player('clap').volume.value = 0 + drumAbsAngle * 4; } catch(e) {} } for (const f of ['thumb', 'index', 'middle', 'ring', 'pinky']) { prevExt[f] = d[f]; } } // --- Finger Expression --- updateFingerExpression(params) { // Wrist angle now handles most modulation in updateGesture } // --- Shape Bass (white quadrilateral = epic bass) --- updateShapeAudio(data) { if (!this.isStarted || this._panicMuted) return; const { type, depth, area, anchorCount } = data; const hasShape = (type !== 'none' && anchorCount >= 2); // Start/stop the shape bass drone if (hasShape && !this._shapeBassActive) { this._shapeBassActive = true; // Bass note follows the pad root if available, else C2 let bassNote = 'C2'; this.padSynths.forEach(pd => { if (pd.currentRoot) { const freq = Tone.Frequency(pd.currentRoot).toFrequency(); bassNote = Tone.Frequency(freq / 2).toNote(); // one octave below pad } }); try { this._shapeBass.triggerAttack(bassNote, Tone.now()); this._shapeSubBass.triggerAttack(Tone.Frequency(bassNote).toFrequency() / 2, Tone.now()); } catch(e) {} } else if (!hasShape && this._shapeBassActive) { this._shapeBassActive = false; try { this._shapeBass.triggerRelease(Tone.now()); this._shapeSubBass.triggerRelease(Tone.now()); } catch(e) {} } if (!hasShape) return; // === Z-DEPTH MODULATION === // depth is typically -0.2 (close) to 0 (far) // Map to 0 (far/bright) → 1 (close/dark) const proximity = Math.max(0, Math.min(1, (-depth - 0) / 0.15)); // Filter: far = open (2000Hz), close = dark (100Hz) if (this._shapeBassFilter) { this._shapeBassFilter.frequency.value = 2000 - proximity * 1800; this._shapeBassFilter.Q.value = 1 + proximity * 6; } // Distortion: more when close if (this._shapeBassDistortion) { this._shapeBassDistortion.distortion = proximity * 0.6; this._shapeBassDistortion.wet.value = proximity * 0.5; } // Volume scales with shape area (bigger shape = louder bass) const vol = -18 + area * 14; try { this._shapeBass.volume.rampTo(vol, 0.03); this._shapeSubBass.volume.rampTo(vol - 4, 0.03); } catch(e) {} // === BASS BEAT PULSE — controlled by synth hand ring finger distance === // Ring finger distance sets beat rate of the shape bass (0=off, 1=fast pulse) const ringDist = this._smoothDist.ring || 0; if (ringDist > 0.15 && this._shapeBassActive) { const beatBPM = 15 + ringDist * 85; // 15-100 BPM, ambient throb const beatMs = 60000 / beatBPM; if (!this._shapeBeatTimer) this._shapeBeatTimer = 0; const now = performance.now(); if (now - this._shapeBeatTimer > beatMs) { this._shapeBeatTimer = now; // Pulse the bass volume for rhythmic throb try { this._shapeBass.volume.rampTo(vol + 6, 0.01); this._shapeBass.volume.rampTo(vol, 0.08); this._shapeSubBass.volume.rampTo(vol, 0.01); this._shapeSubBass.volume.rampTo(vol - 4, 0.08); } catch(e) {} } } // Track pad root for bass note following if (this._shapeBassActive) { this.padSynths.forEach(pd => { if (pd.currentRoot) { const freq = Tone.Frequency(pd.currentRoot).toFrequency(); try { this._shapeBass.frequency.rampTo(freq / 2, 0.1); this._shapeSubBass.frequency.rampTo(freq / 4, 0.1); } catch(e) {} } }); } } // --- Finger Touch Sounds --- // Unique sound per finger, triggered when any two fingertips meet // Pre-allocate touch synths (called once during start) _initTouchSynths() { // Pre-allocated melodic pluck synths — each plays a different pentatonic note // Routed through delay for lush echo cascade (Goa pluck style) this._touchSynths = {}; const touchDest = this._pluckDelay || this.delay || this.limiter; // All 5 fingers get the same pluck voice but play different notes const makePluck = (vol) => { const s = new Tone.Synth({ oscillator: { type: 'triangle' }, envelope: { attack: 0.001, decay: 0.15, sustain: 0, release: 0.12 } }).connect(touchDest); s.volume.value = vol; return s; }; this._touchSynths.thumb = makePluck(-4); this._touchSynths.index = makePluck(-4); this._touchSynths.middle = makePluck(-2); this._touchSynths.ring = makePluck(-2); this._touchSynths.pinky = makePluck(-4); // Pentatonic note per finger (C minor pentatonic, different octaves) this._touchNotes = { thumb: ['C5', 'Eb5', 'G5'], index: ['Bb4', 'C5', 'Eb5'], middle: ['G4', 'Bb4', 'C5'], ring: ['Eb5', 'G5', 'Bb5'], pinky: ['C6', 'Eb6', 'G6'] }; this._touchCooldown = 0; } triggerFingerTouch(finger1, finger2) { if (!this.isStarted || this._panicMuted || !this._touchSynths) return; const now = performance.now(); if ((now - this._touchCooldown) < 100) return; // debounce this._touchCooldown = now; const fingerPriority = ['pinky', 'ring', 'middle', 'index', 'thumb']; const primary = fingerPriority.indexOf(finger1) < fingerPriority.indexOf(finger2) ? finger1 : finger2; try { const notes = this._touchNotes[primary]; if (notes && this._touchSynths[primary]) { const note = notes[Math.floor(Math.random() * notes.length)]; this._touchSynths[primary].triggerAttackRelease(note, '16n', Tone.now(), 0.8); } } catch(e) {} } // --- Timbre Cycling --- cycleSynth() { if (!this.isStarted) return; const activePads = []; this.padSynths.forEach((padData, handId) => { activePads.push({ handId, root: padData.currentRoot }); padData.synth.triggerRelease(Tone.now()); if (padData.thirdSynth) padData.thirdSynth.triggerRelease(Tone.now()); if (padData.fifthSynth) padData.fifthSynth.triggerRelease(Tone.now()); for (const s of [padData.synth, padData.thirdSynth, padData.fifthSynth].filter(Boolean)) { try { s.disconnect(); } catch(e) {} setTimeout(() => { try { s.dispose(); } catch(e) {} }, 1000); } }); this.padSynths.clear(); this.currentSynthIndex = (this.currentSynthIndex + 1) % this.padPresets.length; const preset = this.padPresets[this.currentSynthIndex]; console.log(`Switched to pad preset ${this.currentSynthIndex}: ${preset.name}`); setTimeout(() => { activePads.forEach(({ handId, root }) => { this.startArpeggio(handId, root); }); }, 100); } // --- Proximity Filter --- setProximityFilter(value) { if (this.filter) { const cutoff = 16000 * Math.pow(0.075, value); this.filter.frequency.rampTo(cutoff, 0.2); } } // --- PANIC --- panic() { this._panicMuted = true; setTimeout(() => { this._panicMuted = false; }, 1000); this.padSynths.forEach((padData) => { try { padData.synth.triggerRelease(Tone.now()); } catch(e) {} try { if (padData.harmonySynth) padData.harmonySynth.triggerRelease(Tone.now()); } catch(e) {} setTimeout(() => { try { padData.synth.dispose(); } catch(e) {} try { if (padData.harmonySynth) padData.harmonySynth.dispose(); } catch(e) {} }, 500); }); this.padSynths.clear(); this.handVolumes.clear(); this.fingerCooldowns.clear(); if (this.subBass) try { this.subBass.triggerRelease(Tone.now()); } catch(e) {} try { this.middleContinuous.triggerRelease(Tone.now()); } catch(e) {} try { this.ringContinuous.triggerRelease(Tone.now()); } catch(e) {} try { this.pinkyContinuous.triggerRelease(Tone.now()); } catch(e) {} this._continuousActive = false; // Reset glitch beat timers this._drumBeatTimers = { kick: 0, hihat: 0, clap: 0 }; this._shapeBeatTimer = 0; // Stop shape bass this._shapeBassActive = false; try { this._shapeBass.triggerRelease(Tone.now()); } catch(e) {} try { this._shapeSubBass.triggerRelease(Tone.now()); } catch(e) {} if (this._shapeBassFilter) this._shapeBassFilter.frequency.value = 800; if (this._shapeBassDistortion) { this._shapeBassDistortion.distortion = 0; this._shapeBassDistortion.wet.value = 0; } if (this.fingerSynths) { for (const finger of ['index', 'middle', 'ring', 'pinky']) { try { this.fingerSynths[finger].triggerRelease(Tone.now()); } catch(e) {} } } if (this.wobbleLFO) { this.wobbleLFO.stop(); setTimeout(() => { try { this.wobbleLFO.start(); } catch(e) {} }, 1000); } if (this.filter) this.filter.frequency.value = 16000; if (this.reverb) this.reverb.wet.value = 0.35; if (this.delay) { this.delay.wet.value = 0.2; this.delay.feedback.value = 0.35; } if (this.chorus) this.chorus.depth = 0.6; if (this.distortion) { this.distortion.distortion = 0; this.distortion.wet.value = 0; } this._prevDrumExtensions = { thumb: 0, index: 0, middle: 0, ring: 0, pinky: 0 }; this._drumFingerCooldowns = { thumb: 0, index: 0, middle: 0, ring: 0, pinky: 0 }; this._smoothDist = { thumb: 0, index: 0, middle: 0, ring: 0, pinky: 0 }; this._smoothDrumDist = { thumb: 0, index: 0, middle: 0, ring: 0, pinky: 0 }; console.log('PANIC — all sound killed'); } getAnalyser() { return this.analyser; } }