Created
March 19, 2026 20:00
-
-
Save o-az/042e99e5488d377100452872c0be54a0 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <!DOCTYPE html> | |
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <title>Trance Generator</title> | |
| <style> | |
| * { margin: 0; padding: 0; box-sizing: border-box; } | |
| body { | |
| background: #0a0a1a; | |
| color: #e0e0ff; | |
| font-family: 'Segoe UI', system-ui, sans-serif; | |
| min-height: 100vh; | |
| display: flex; | |
| flex-direction: column; | |
| align-items: center; | |
| overflow-x: hidden; | |
| } | |
| canvas#bg { | |
| position: fixed; | |
| top: 0; left: 0; | |
| width: 100%; height: 100%; | |
| z-index: 0; | |
| } | |
| .container { | |
| position: relative; | |
| z-index: 1; | |
| width: 100%; | |
| max-width: 800px; | |
| padding: 30px 20px; | |
| } | |
| h1 { | |
| text-align: center; | |
| font-size: 2.2rem; | |
| background: linear-gradient(135deg, #00d4ff, #a855f7, #ff6ec7); | |
| -webkit-background-clip: text; | |
| -webkit-text-fill-color: transparent; | |
| margin-bottom: 8px; | |
| letter-spacing: 2px; | |
| } | |
| .subtitle { | |
| text-align: center; | |
| color: #7a7aaa; | |
| font-size: 0.9rem; | |
| margin-bottom: 30px; | |
| } | |
| .main-btn { | |
| display: block; | |
| margin: 0 auto 30px; | |
| padding: 16px 60px; | |
| font-size: 1.3rem; | |
| font-weight: 700; | |
| letter-spacing: 3px; | |
| text-transform: uppercase; | |
| border: none; | |
| border-radius: 50px; | |
| cursor: pointer; | |
| transition: all 0.3s; | |
| color: #fff; | |
| } | |
| .main-btn.play { | |
| background: linear-gradient(135deg, #a855f7, #6d28d9); | |
| box-shadow: 0 0 30px rgba(168,85,247,0.4); | |
| } | |
| .main-btn.play:hover { | |
| box-shadow: 0 0 50px rgba(168,85,247,0.7); | |
| transform: scale(1.05); | |
| } | |
| .main-btn.stop { | |
| background: linear-gradient(135deg, #ef4444, #b91c1c); | |
| box-shadow: 0 0 30px rgba(239,68,68,0.4); | |
| } | |
| .panel { | |
| background: rgba(20, 20, 45, 0.85); | |
| border: 1px solid rgba(168,85,247,0.2); | |
| border-radius: 16px; | |
| padding: 24px; | |
| margin-bottom: 20px; | |
| backdrop-filter: blur(10px); | |
| } | |
| .panel h2 { | |
| font-size: 1rem; | |
| color: #a855f7; | |
| margin-bottom: 16px; | |
| text-transform: uppercase; | |
| letter-spacing: 2px; | |
| } | |
| .controls-grid { | |
| display: grid; | |
| grid-template-columns: 1fr 1fr; | |
| gap: 16px; | |
| } | |
| .control { | |
| display: flex; | |
| flex-direction: column; | |
| } | |
| .control label { | |
| font-size: 0.78rem; | |
| color: #8888bb; | |
| margin-bottom: 6px; | |
| text-transform: uppercase; | |
| letter-spacing: 1px; | |
| } | |
| .control .value { | |
| font-size: 0.75rem; | |
| color: #a855f7; | |
| text-align: right; | |
| margin-bottom: 2px; | |
| } | |
| input[type="range"] { | |
| -webkit-appearance: none; | |
| width: 100%; | |
| height: 6px; | |
| border-radius: 3px; | |
| background: #1a1a3a; | |
| outline: none; | |
| } | |
| input[type="range"]::-webkit-slider-thumb { | |
| -webkit-appearance: none; | |
| width: 18px; | |
| height: 18px; | |
| border-radius: 50%; | |
| background: linear-gradient(135deg, #a855f7, #00d4ff); | |
| cursor: pointer; | |
| box-shadow: 0 0 8px rgba(168,85,247,0.5); | |
| } | |
| select { | |
| background: #1a1a3a; | |
| color: #e0e0ff; | |
| border: 1px solid rgba(168,85,247,0.3); | |
| border-radius: 8px; | |
| padding: 8px 12px; | |
| font-size: 0.9rem; | |
| cursor: pointer; | |
| outline: none; | |
| } | |
| .toggle-row { | |
| display: flex; | |
| gap: 10px; | |
| flex-wrap: wrap; | |
| margin-top: 4px; | |
| } | |
| .toggle-btn { | |
| padding: 8px 16px; | |
| border: 1px solid rgba(168,85,247,0.3); | |
| border-radius: 20px; | |
| background: transparent; | |
| color: #8888bb; | |
| font-size: 0.8rem; | |
| cursor: pointer; | |
| transition: all 0.2s; | |
| text-transform: uppercase; | |
| letter-spacing: 1px; | |
| } | |
| .toggle-btn.active { | |
| background: rgba(168,85,247,0.25); | |
| border-color: #a855f7; | |
| color: #e0e0ff; | |
| box-shadow: 0 0 12px rgba(168,85,247,0.2); | |
| } | |
| .preset-row { | |
| display: flex; | |
| gap: 10px; | |
| flex-wrap: wrap; | |
| justify-content: center; | |
| margin-bottom: 20px; | |
| } | |
| .preset-btn { | |
| padding: 8px 20px; | |
| border: 1px solid rgba(0,212,255,0.3); | |
| border-radius: 20px; | |
| background: rgba(0,212,255,0.08); | |
| color: #00d4ff; | |
| font-size: 0.8rem; | |
| cursor: pointer; | |
| transition: all 0.2s; | |
| text-transform: uppercase; | |
| letter-spacing: 1px; | |
| } | |
| .preset-btn:hover { | |
| background: rgba(0,212,255,0.2); | |
| box-shadow: 0 0 15px rgba(0,212,255,0.2); | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| <canvas id="bg"></canvas> | |
| <div class="container"> | |
| <h1>TRANCE GENERATOR</h1> | |
| <p class="subtitle">Procedural trance music synthesis</p> | |
| <div class="preset-row"> | |
| <button class="preset-btn" onclick="loadPreset('classic')">Classic Trance</button> | |
| <button class="preset-btn" onclick="loadPreset('uplifting')">Uplifting</button> | |
| <button class="preset-btn" onclick="loadPreset('psy')">Psytrance</button> | |
| <button class="preset-btn" onclick="loadPreset('deep')">Deep Trance</button> | |
| <button class="preset-btn" onclick="loadPreset('acid')">Acid</button> | |
| </div> | |
| <button id="mainBtn" class="main-btn play" onclick="togglePlay()">PLAY</button> | |
| <div class="panel"> | |
| <h2>Tempo & Key</h2> | |
| <div class="controls-grid"> | |
| <div class="control"> | |
| <label>BPM</label> | |
| <div class="value" id="bpmVal">138</div> | |
| <input type="range" id="bpm" min="120" max="160" value="138" oninput="updateParam('bpm')"> | |
| </div> | |
| <div class="control"> | |
| <label>Key</label> | |
| <select id="key" onchange="updateParam('key')"> | |
| <option value="0">C minor</option> | |
| <option value="1">C# minor</option> | |
| <option value="2" selected>D minor</option> | |
| <option value="3">Eb minor</option> | |
| <option value="4">E minor</option> | |
| <option value="5">F minor</option> | |
| <option value="7">G minor</option> | |
| <option value="9">A minor</option> | |
| <option value="10">Bb minor</option> | |
| </select> | |
| </div> | |
| </div> | |
| </div> | |
| <div class="panel"> | |
| <h2>Layers</h2> | |
| <div class="toggle-row"> | |
| <button class="toggle-btn active" id="tog_kick" onclick="toggleLayer('kick')">Kick</button> | |
| <button class="toggle-btn active" id="tog_bass" onclick="toggleLayer('bass')">Bass</button> | |
| <button class="toggle-btn active" id="tog_arp" onclick="toggleLayer('arp')">Arpeggio</button> | |
| <button class="toggle-btn active" id="tog_pad" onclick="toggleLayer('pad')">Pad</button> | |
| <button class="toggle-btn active" id="tog_hihat" onclick="toggleLayer('hihat')">Hi-Hat</button> | |
| <button class="toggle-btn" id="tog_lead" onclick="toggleLayer('lead')">Lead</button> | |
| <button class="toggle-btn" id="tog_clap" onclick="toggleLayer('clap')">Clap</button> | |
| </div> | |
| </div> | |
| <div class="panel"> | |
| <h2>Sound Design</h2> | |
| <div class="controls-grid"> | |
| <div class="control"> | |
| <label>Arp Speed</label> | |
| <div class="value" id="arpSpeedVal">1/16</div> | |
| <input type="range" id="arpSpeed" min="0" max="2" value="1" step="1" oninput="updateParam('arpSpeed')"> | |
| </div> | |
| <div class="control"> | |
| <label>Arp Cutoff</label> | |
| <div class="value" id="arpCutoffVal">2200 Hz</div> | |
| <input type="range" id="arpCutoff" min="400" max="6000" value="2200" oninput="updateParam('arpCutoff')"> | |
| </div> | |
| <div class="control"> | |
| <label>Bass Intensity</label> | |
| <div class="value" id="bassIntVal">70%</div> | |
| <input type="range" id="bassInt" min="0" max="100" value="70" oninput="updateParam('bassInt')"> | |
| </div> | |
| <div class="control"> | |
| <label>Pad Warmth</label> | |
| <div class="value" id="padWarmthVal">60%</div> | |
| <input type="range" id="padWarmth" min="0" max="100" value="60" oninput="updateParam('padWarmth')"> | |
| </div> | |
| <div class="control"> | |
| <label>Lead Detune</label> | |
| <div class="value" id="leadDetuneVal">15 ct</div> | |
| <input type="range" id="leadDetune" min="0" max="50" value="15" oninput="updateParam('leadDetune')"> | |
| </div> | |
| <div class="control"> | |
| <label>Reverb</label> | |
| <div class="value" id="reverbVal">50%</div> | |
| <input type="range" id="reverb" min="0" max="100" value="50" oninput="updateParam('reverb')"> | |
| </div> | |
| <div class="control"> | |
| <label>Delay Feedback</label> | |
| <div class="value" id="delayVal">35%</div> | |
| <input type="range" id="delay" min="0" max="80" value="35" oninput="updateParam('delay')"> | |
| </div> | |
| <div class="control"> | |
| <label>Master Volume</label> | |
| <div class="value" id="masterVal">75%</div> | |
| <input type="range" id="master" min="0" max="100" value="75" oninput="updateParam('master')"> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <script> | |
| // ---- Audio Engine ---- | |
| let ctx, masterGain, reverbNode, delayNode, delayFeedback, analyser; | |
| let playing = false; | |
| let schedulerTimer = null; | |
| let nextNoteTime = 0; | |
| let currentStep = 0; | |
| const SCHEDULE_AHEAD = 0.1; | |
| const LOOKAHEAD = 25; | |
| const state = { | |
| bpm: 138, key: 2, | |
| layers: { kick: true, bass: true, arp: true, pad: true, hihat: true, lead: false, clap: false }, | |
| arpSpeed: 1, arpCutoff: 2200, | |
| bassInt: 70, padWarmth: 60, leadDetune: 15, | |
| reverbMix: 0.5, delayFb: 0.35, master: 0.75 | |
| }; | |
| // Minor scale intervals | |
| const MINOR = [0, 2, 3, 5, 7, 8, 10]; | |
| function noteFreq(midi) { return 440 * Math.pow(2, (midi - 69) / 12); } | |
| function scaleNote(root, degree) { | |
| const oct = Math.floor(degree / 7); | |
| const idx = ((degree % 7) + 7) % 7; | |
| return root + oct * 12 + MINOR[idx]; | |
| } | |
| // Chord progressions (in scale degrees) - classic trance: i - VI - III - VII | |
| const PROGRESSIONS = [ | |
| [[0,2,4],[5,7,9],[2,4,6],[6,8,10]], // i VI III VII | |
| [[0,2,4],[3,5,7],[4,6,8],[3,5,7]], // i iv v iv | |
| [[0,2,4],[5,7,9],[3,5,7],[4,6,8]], // i VI iv v | |
| ]; | |
| let currentProg = 0; | |
| function getChord(bar) { | |
| const prog = PROGRESSIONS[currentProg % PROGRESSIONS.length]; | |
| return prog[bar % prog.length]; | |
| } | |
| // Arp patterns | |
| const ARP_PATTERNS = [ | |
| [0, 1, 2, 1, 0, 2, 1, 2], | |
| [0, 2, 1, 2, 0, 1, 2, 0], | |
| [2, 1, 0, 1, 2, 0, 1, 0], | |
| ]; | |
| let currentArpPattern = 0; | |
| function createReverb() { | |
| const conv = ctx.createConvolver(); | |
| const rate = ctx.sampleRate; | |
| const len = rate * 2.5; | |
| const buf = ctx.createBuffer(2, len, rate); | |
| for (let ch = 0; ch < 2; ch++) { | |
| const d = buf.getChannelData(ch); | |
| for (let i = 0; i < len; i++) { | |
| d[i] = (Math.random() * 2 - 1) * Math.pow(1 - i / len, 2.5); | |
| } | |
| } | |
| conv.buffer = buf; | |
| return conv; | |
| } | |
| function initAudio() { | |
| ctx = new (window.AudioContext || window.webkitAudioContext)(); | |
| masterGain = ctx.createGain(); | |
| masterGain.gain.value = state.master; | |
| analyser = ctx.createAnalyser(); | |
| analyser.fftSize = 256; | |
| reverbNode = createReverb(); | |
| const reverbGain = ctx.createGain(); | |
| reverbGain.gain.value = state.reverbMix; | |
| reverbGain._id = 'reverbGain'; | |
| window._reverbGain = reverbGain; | |
| const dryGain = ctx.createGain(); | |
| dryGain.gain.value = 1 - state.reverbMix * 0.5; | |
| window._dryGain = dryGain; | |
| delayNode = ctx.createDelay(1); | |
| delayNode.delayTime.value = 60 / state.bpm * 0.75; | |
| delayFeedback = ctx.createGain(); | |
| delayFeedback.gain.value = state.delayFb; | |
| // Routing | |
| masterGain.connect(dryGain); | |
| masterGain.connect(reverbGain); | |
| reverbGain.connect(reverbNode); | |
| reverbNode.connect(analyser); | |
| dryGain.connect(analyser); | |
| masterGain.connect(delayNode); | |
| delayNode.connect(delayFeedback); | |
| delayFeedback.connect(delayNode); | |
| delayNode.connect(analyser); | |
| analyser.connect(ctx.destination); | |
| } | |
| // ---- Synth Voices ---- | |
| function playKick(time) { | |
| const osc = ctx.createOscillator(); | |
| const gain = ctx.createGain(); | |
| osc.type = 'sine'; | |
| osc.frequency.setValueAtTime(150, time); | |
| osc.frequency.exponentialRampToValueAtTime(30, time + 0.12); | |
| gain.gain.setValueAtTime(0.9, time); | |
| gain.gain.exponentialRampToValueAtTime(0.001, time + 0.4); | |
| osc.connect(gain); | |
| gain.connect(masterGain); | |
| osc.start(time); | |
| osc.stop(time + 0.4); | |
| // Click layer | |
| const osc2 = ctx.createOscillator(); | |
| const g2 = ctx.createGain(); | |
| osc2.type = 'sine'; | |
| osc2.frequency.setValueAtTime(1000, time); | |
| osc2.frequency.exponentialRampToValueAtTime(60, time + 0.02); | |
| g2.gain.setValueAtTime(0.6, time); | |
| g2.gain.exponentialRampToValueAtTime(0.001, time + 0.04); | |
| osc2.connect(g2); | |
| g2.connect(masterGain); | |
| osc2.start(time); | |
| osc2.stop(time + 0.05); | |
| } | |
| function playHiHat(time, open) { | |
| const bufSize = ctx.sampleRate * 0.05; | |
| const buf = ctx.createBuffer(1, bufSize, ctx.sampleRate); | |
| const data = buf.getChannelData(0); | |
| for (let i = 0; i < bufSize; i++) data[i] = Math.random() * 2 - 1; | |
| const src = ctx.createBufferSource(); | |
| src.buffer = buf; | |
| const hp = ctx.createBiquadFilter(); | |
| hp.type = 'highpass'; | |
| hp.frequency.value = 7000; | |
| const gain = ctx.createGain(); | |
| const dur = open ? 0.15 : 0.05; | |
| gain.gain.setValueAtTime(open ? 0.18 : 0.15, time); | |
| gain.gain.exponentialRampToValueAtTime(0.001, time + dur); | |
| src.connect(hp); | |
| hp.connect(gain); | |
| gain.connect(masterGain); | |
| src.start(time); | |
| src.stop(time + dur + 0.01); | |
| } | |
| function playClap(time) { | |
| const bufSize = ctx.sampleRate * 0.1; | |
| const buf = ctx.createBuffer(1, bufSize, ctx.sampleRate); | |
| const data = buf.getChannelData(0); | |
| for (let i = 0; i < bufSize; i++) { | |
| const env = Math.exp(-i / (ctx.sampleRate * 0.03)); | |
| data[i] = (Math.random() * 2 - 1) * env; | |
| } | |
| const src = ctx.createBufferSource(); | |
| src.buffer = buf; | |
| const bp = ctx.createBiquadFilter(); | |
| bp.type = 'bandpass'; | |
| bp.frequency.value = 1200; | |
| bp.Q.value = 1.5; | |
| const gain = ctx.createGain(); | |
| gain.gain.setValueAtTime(0.35, time); | |
| gain.gain.exponentialRampToValueAtTime(0.001, time + 0.15); | |
| src.connect(bp); | |
| bp.connect(gain); | |
| gain.connect(masterGain); | |
| src.start(time); | |
| src.stop(time + 0.2); | |
| } | |
| function playBass(time, freq, dur) { | |
| const osc = ctx.createOscillator(); | |
| const osc2 = ctx.createOscillator(); | |
| const gain = ctx.createGain(); | |
| const filter = ctx.createBiquadFilter(); | |
| osc.type = 'sawtooth'; | |
| osc2.type = 'square'; | |
| osc.frequency.setValueAtTime(freq, time); | |
| osc2.frequency.setValueAtTime(freq * 1.002, time); | |
| filter.type = 'lowpass'; | |
| filter.frequency.setValueAtTime(200 + state.bassInt * 15, time); | |
| filter.frequency.exponentialRampToValueAtTime(80, time + dur * 0.8); | |
| filter.Q.value = 5; | |
| const vol = state.bassInt / 100 * 0.4; | |
| gain.gain.setValueAtTime(vol, time); | |
| gain.gain.setValueAtTime(vol, time + dur * 0.7); | |
| gain.gain.exponentialRampToValueAtTime(0.001, time + dur); | |
| osc.connect(filter); | |
| osc2.connect(filter); | |
| filter.connect(gain); | |
| gain.connect(masterGain); | |
| osc.start(time); | |
| osc2.start(time); | |
| osc.stop(time + dur + 0.01); | |
| osc2.stop(time + dur + 0.01); | |
| } | |
| let padOscs = []; | |
| function updatePad(time, chord, root) { | |
| stopPad(time); | |
| const gain = ctx.createGain(); | |
| const filter = ctx.createBiquadFilter(); | |
| filter.type = 'lowpass'; | |
| filter.frequency.value = 600 + state.padWarmth * 30; | |
| filter.Q.value = 1; | |
| gain.gain.setValueAtTime(0, time); | |
| gain.gain.linearRampToValueAtTime(0.12, time + 0.8); | |
| const oscs = []; | |
| chord.forEach(deg => { | |
| const midi = scaleNote(root + 48, deg); | |
| ['sine', 'triangle'].forEach((type, i) => { | |
| const osc = ctx.createOscillator(); | |
| osc.type = type; | |
| osc.frequency.value = noteFreq(midi) * (i === 1 ? 1.003 : 1); | |
| osc.connect(filter); | |
| osc.start(time); | |
| oscs.push(osc); | |
| }); | |
| }); | |
| filter.connect(gain); | |
| gain.connect(masterGain); | |
| padOscs = { oscs, gain, filter }; | |
| } | |
| function stopPad(time) { | |
| if (padOscs.oscs) { | |
| padOscs.gain.gain.setValueAtTime(padOscs.gain.gain.value, time); | |
| padOscs.gain.gain.linearRampToValueAtTime(0, time + 0.3); | |
| const o = padOscs.oscs; | |
| setTimeout(() => o.forEach(x => { try { x.stop(); } catch(e){} }), 500); | |
| padOscs = []; | |
| } | |
| } | |
| function playArp(time, chord, root) { | |
| const pattern = ARP_PATTERNS[currentArpPattern % ARP_PATTERNS.length]; | |
| const stepInPattern = currentStep % pattern.length; | |
| const deg = chord[pattern[stepInPattern] % chord.length]; | |
| const midi = scaleNote(root + 60, deg); | |
| const freq = noteFreq(midi); | |
| const osc = ctx.createOscillator(); | |
| const osc2 = ctx.createOscillator(); | |
| const gain = ctx.createGain(); | |
| const filter = ctx.createBiquadFilter(); | |
| osc.type = 'sawtooth'; | |
| osc2.type = 'square'; | |
| osc.frequency.value = freq; | |
| osc2.frequency.value = freq * 0.999; | |
| filter.type = 'lowpass'; | |
| const stepDur = getStepDuration(); | |
| filter.frequency.setValueAtTime(state.arpCutoff, time); | |
| filter.frequency.exponentialRampToValueAtTime(Math.max(state.arpCutoff * 0.3, 100), time + stepDur * 0.8); | |
| filter.Q.value = 6; | |
| gain.gain.setValueAtTime(0.22, time); | |
| gain.gain.setValueAtTime(0.22, time + stepDur * 0.6); | |
| gain.gain.exponentialRampToValueAtTime(0.001, time + stepDur * 0.95); | |
| osc.connect(filter); | |
| osc2.connect(filter); | |
| filter.connect(gain); | |
| gain.connect(masterGain); | |
| osc.start(time); | |
| osc2.start(time); | |
| osc.stop(time + stepDur); | |
| osc2.stop(time + stepDur); | |
| } | |
| function playLead(time, chord, root) { | |
| if (currentStep % 4 !== 0) return; | |
| const deg = chord[0] + 7; | |
| const midi = scaleNote(root + 60, deg); | |
| const freq = noteFreq(midi); | |
| const dur = getStepDuration() * 3; | |
| const osc = ctx.createOscillator(); | |
| const osc2 = ctx.createOscillator(); | |
| const gain = ctx.createGain(); | |
| const filter = ctx.createBiquadFilter(); | |
| osc.type = 'sawtooth'; | |
| osc2.type = 'sawtooth'; | |
| osc.frequency.value = freq; | |
| osc2.frequency.value = freq + state.leadDetune * 0.1; | |
| filter.type = 'lowpass'; | |
| filter.frequency.setValueAtTime(3000, time); | |
| filter.frequency.exponentialRampToValueAtTime(800, time + dur); | |
| filter.Q.value = 3; | |
| gain.gain.setValueAtTime(0, time); | |
| gain.gain.linearRampToValueAtTime(0.15, time + 0.05); | |
| gain.gain.setValueAtTime(0.15, time + dur * 0.6); | |
| gain.gain.exponentialRampToValueAtTime(0.001, time + dur); | |
| osc.connect(filter); | |
| osc2.connect(filter); | |
| filter.connect(gain); | |
| gain.connect(masterGain); | |
| osc.start(time); | |
| osc2.start(time); | |
| osc.stop(time + dur + 0.01); | |
| osc2.stop(time + dur + 0.01); | |
| } | |
| function getStepDuration() { | |
| const sixteenth = 60 / state.bpm / 4; | |
| if (state.arpSpeed === 0) return sixteenth * 2; // 1/8 | |
| if (state.arpSpeed === 1) return sixteenth; // 1/16 | |
| return sixteenth / 2; // 1/32 | |
| } | |
| function scheduleStep(time) { | |
| const stepsPerBeat = state.arpSpeed === 0 ? 2 : state.arpSpeed === 1 ? 4 : 8; | |
| const stepsPerBar = stepsPerBeat * 4; | |
| const bar = Math.floor(currentStep / stepsPerBar); | |
| const beatStep = currentStep % stepsPerBeat; | |
| const barStep = currentStep % stepsPerBar; | |
| const chord = getChord(bar); | |
| const root = state.key; | |
| // Kick: every beat | |
| if (state.layers.kick && barStep % stepsPerBeat === 0) { | |
| playKick(time); | |
| } | |
| // Hi-hat: offbeat 16ths | |
| if (state.layers.hihat && stepsPerBeat >= 4) { | |
| if (barStep % (stepsPerBeat / 2) === Math.floor(stepsPerBeat / 4)) { | |
| playHiHat(time, false); | |
| } else if (barStep % stepsPerBeat === Math.floor(stepsPerBeat / 2)) { | |
| playHiHat(time, true); | |
| } | |
| } else if (state.layers.hihat) { | |
| if (beatStep === 1) playHiHat(time, barStep % (stepsPerBeat * 2) === stepsPerBeat + 1); | |
| } | |
| // Clap: beats 2 and 4 | |
| if (state.layers.clap && barStep % stepsPerBeat === 0) { | |
| const beat = Math.floor(barStep / stepsPerBeat); | |
| if (beat === 1 || beat === 3) playClap(time); | |
| } | |
| // Bass: every beat | |
| if (state.layers.bass && barStep % stepsPerBeat === 0) { | |
| const bassMidi = scaleNote(root + 36, chord[0]); | |
| playBass(time, noteFreq(bassMidi), 60 / state.bpm * 0.8); | |
| } | |
| // Pad: update on bar change | |
| if (state.layers.pad && barStep === 0) { | |
| updatePad(time, chord, root); | |
| } | |
| // Arp | |
| if (state.layers.arp) { | |
| playArp(time, chord, root); | |
| } | |
| // Lead | |
| if (state.layers.lead) { | |
| playLead(time, chord, root); | |
| } | |
| // Change progression every 8 bars | |
| if (currentStep > 0 && currentStep % (stepsPerBar * 8) === 0) { | |
| currentProg++; | |
| currentArpPattern++; | |
| } | |
| } | |
| function scheduler() { | |
| while (nextNoteTime < ctx.currentTime + SCHEDULE_AHEAD) { | |
| scheduleStep(nextNoteTime); | |
| currentStep++; | |
| nextNoteTime += getStepDuration(); | |
| } | |
| } | |
| function togglePlay() { | |
| const btn = document.getElementById('mainBtn'); | |
| if (!playing) { | |
| if (!ctx) initAudio(); | |
| if (ctx.state === 'suspended') ctx.resume(); | |
| playing = true; | |
| currentStep = 0; | |
| nextNoteTime = ctx.currentTime; | |
| schedulerTimer = setInterval(scheduler, LOOKAHEAD); | |
| btn.textContent = 'STOP'; | |
| btn.className = 'main-btn stop'; | |
| startVisualizer(); | |
| } else { | |
| playing = false; | |
| clearInterval(schedulerTimer); | |
| stopPad(ctx.currentTime); | |
| btn.textContent = 'PLAY'; | |
| btn.className = 'main-btn play'; | |
| } | |
| } | |
| function toggleLayer(name) { | |
| state.layers[name] = !state.layers[name]; | |
| const btn = document.getElementById('tog_' + name); | |
| btn.classList.toggle('active'); | |
| if (name === 'pad' && !state.layers.pad) stopPad(ctx ? ctx.currentTime : 0); | |
| } | |
| function updateParam(id) { | |
| const el = document.getElementById(id); | |
| const v = parseFloat(el.value); | |
| switch(id) { | |
| case 'bpm': | |
| state.bpm = v; | |
| document.getElementById('bpmVal').textContent = v; | |
| if (delayNode) delayNode.delayTime.value = 60 / v * 0.75; | |
| break; | |
| case 'key': | |
| state.key = parseInt(v); | |
| break; | |
| case 'arpSpeed': | |
| state.arpSpeed = v; | |
| document.getElementById('arpSpeedVal').textContent = v === 0 ? '1/8' : v === 1 ? '1/16' : '1/32'; | |
| break; | |
| case 'arpCutoff': | |
| state.arpCutoff = v; | |
| document.getElementById('arpCutoffVal').textContent = Math.round(v) + ' Hz'; | |
| break; | |
| case 'bassInt': | |
| state.bassInt = v; | |
| document.getElementById('bassIntVal').textContent = v + '%'; | |
| break; | |
| case 'padWarmth': | |
| state.padWarmth = v; | |
| document.getElementById('padWarmthVal').textContent = v + '%'; | |
| if (padOscs.filter) padOscs.filter.frequency.value = 600 + v * 30; | |
| break; | |
| case 'leadDetune': | |
| state.leadDetune = v; | |
| document.getElementById('leadDetuneVal').textContent = v + ' ct'; | |
| break; | |
| case 'reverb': | |
| state.reverbMix = v / 100; | |
| document.getElementById('reverbVal').textContent = v + '%'; | |
| if (window._reverbGain) window._reverbGain.gain.value = state.reverbMix; | |
| if (window._dryGain) window._dryGain.gain.value = 1 - state.reverbMix * 0.5; | |
| break; | |
| case 'delay': | |
| state.delayFb = v / 100; | |
| document.getElementById('delayVal').textContent = v + '%'; | |
| if (delayFeedback) delayFeedback.gain.value = state.delayFb; | |
| break; | |
| case 'master': | |
| state.master = v / 100; | |
| document.getElementById('masterVal').textContent = v + '%'; | |
| if (masterGain) masterGain.gain.value = state.master; | |
| break; | |
| } | |
| } | |
| function loadPreset(name) { | |
| const presets = { | |
| classic: { bpm: 138, key: 2, arpSpeed: 1, arpCutoff: 2200, bassInt: 70, padWarmth: 60, leadDetune: 15, reverb: 50, delay: 35, master: 75, | |
| layers: { kick:true, bass:true, arp:true, pad:true, hihat:true, lead:false, clap:false }}, | |
| uplifting: { bpm: 140, key: 4, arpSpeed: 1, arpCutoff: 3500, bassInt: 60, padWarmth: 80, leadDetune: 20, reverb: 65, delay: 45, master: 75, | |
| layers: { kick:true, bass:true, arp:true, pad:true, hihat:true, lead:true, clap:true }}, | |
| psy: { bpm: 148, key: 0, arpSpeed: 2, arpCutoff: 4500, bassInt: 90, padWarmth: 30, leadDetune: 5, reverb: 30, delay: 25, master: 70, | |
| layers: { kick:true, bass:true, arp:true, pad:false, hihat:true, lead:false, clap:false }}, | |
| deep: { bpm: 128, key: 9, arpSpeed: 0, arpCutoff: 1200, bassInt: 50, padWarmth: 90, leadDetune: 25, reverb: 70, delay: 50, master: 70, | |
| layers: { kick:true, bass:true, arp:true, pad:true, hihat:false, lead:false, clap:false }}, | |
| acid: { bpm: 142, key: 5, arpSpeed: 1, arpCutoff: 5000, bassInt: 85, padWarmth: 40, leadDetune: 10, reverb: 35, delay: 55, master: 75, | |
| layers: { kick:true, bass:true, arp:true, pad:false, hihat:true, lead:true, clap:true }}, | |
| }; | |
| const p = presets[name]; | |
| if (!p) return; | |
| // Set slider values | |
| const setSlider = (id, val) => { document.getElementById(id).value = val; updateParam(id); }; | |
| setSlider('bpm', p.bpm); | |
| document.getElementById('key').value = p.key; updateParam('key'); | |
| setSlider('arpSpeed', p.arpSpeed); | |
| setSlider('arpCutoff', p.arpCutoff); | |
| setSlider('bassInt', p.bassInt); | |
| setSlider('padWarmth', p.padWarmth); | |
| setSlider('leadDetune', p.leadDetune); | |
| setSlider('reverb', p.reverb); | |
| setSlider('delay', p.delay); | |
| setSlider('master', p.master); | |
| Object.keys(p.layers).forEach(l => { | |
| if (state.layers[l] !== p.layers[l]) toggleLayer(l); | |
| }); | |
| } | |
| // ---- Visualizer ---- | |
| const canvas = document.getElementById('bg'); | |
| const canvasCtx = canvas.getContext('2d'); | |
| let animFrame; | |
| function resizeCanvas() { | |
| canvas.width = window.innerWidth; | |
| canvas.height = window.innerHeight; | |
| } | |
| window.addEventListener('resize', resizeCanvas); | |
| resizeCanvas(); | |
| function startVisualizer() { | |
| if (animFrame) return; | |
| drawVisualizer(); | |
| } | |
| function drawVisualizer() { | |
| animFrame = requestAnimationFrame(drawVisualizer); | |
| const W = canvas.width, H = canvas.height; | |
| canvasCtx.fillStyle = 'rgba(10, 10, 26, 0.15)'; | |
| canvasCtx.fillRect(0, 0, W, H); | |
| if (!analyser || !playing) { | |
| canvasCtx.fillStyle = 'rgba(10, 10, 26, 1)'; | |
| canvasCtx.fillRect(0, 0, W, H); | |
| if (!playing) { animFrame = null; return; } | |
| } | |
| const bufLen = analyser.frequencyBinCount; | |
| const data = new Uint8Array(bufLen); | |
| analyser.getByteFrequencyData(data); | |
| // Bars | |
| const barW = W / bufLen * 2.5; | |
| for (let i = 0; i < bufLen; i++) { | |
| const v = data[i] / 255; | |
| const h = v * H * 0.6; | |
| const hue = 260 + v * 60; | |
| canvasCtx.fillStyle = `hsla(${hue}, 80%, ${40 + v * 30}%, ${0.4 + v * 0.4})`; | |
| canvasCtx.fillRect(i * barW, H - h, barW - 1, h); | |
| } | |
| // Waveform | |
| const timeData = new Uint8Array(bufLen); | |
| analyser.getByteTimeDomainData(timeData); | |
| canvasCtx.strokeStyle = 'rgba(168, 85, 247, 0.5)'; | |
| canvasCtx.lineWidth = 2; | |
| canvasCtx.beginPath(); | |
| const sliceW = W / bufLen; | |
| for (let i = 0; i < bufLen; i++) { | |
| const y = (timeData[i] / 128) * H / 2; | |
| if (i === 0) canvasCtx.moveTo(0, y); | |
| else canvasCtx.lineTo(i * sliceW, y); | |
| } | |
| canvasCtx.stroke(); | |
| } | |
| // Start with static background | |
| canvasCtx.fillStyle = '#0a0a1a'; | |
| canvasCtx.fillRect(0, 0, canvas.width, canvas.height); | |
| </script> | |
| </body> | |
| </html> |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment