Last active
January 28, 2026 06:31
-
-
Save rndmcnlly/d77469c94edb1e6c4930de57baebac75 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, user-scalable=no"> | |
| <title>Ambient Synth Rack</title> | |
| <style> | |
| * { margin: 0; padding: 0; box-sizing: border-box; -webkit-user-select: none; user-select: none; } | |
| body { | |
| min-height: 100vh; | |
| background: linear-gradient(135deg, #d4d4d8 0%, #e4e4e7 50%, #d8d8dc 100%); | |
| font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif; | |
| color: #374151; | |
| padding: 16px; | |
| overflow-x: hidden; | |
| } | |
| .container { | |
| max-width: 900px; | |
| margin: 0 auto; | |
| } | |
| header { | |
| display: flex; | |
| align-items: center; | |
| justify-content: space-between; | |
| margin-bottom: 20px; | |
| padding-bottom: 16px; | |
| border-bottom: 1px solid rgba(0,0,0,0.1); | |
| } | |
| h1 { | |
| font-size: 14px; | |
| font-weight: 600; | |
| letter-spacing: 1px; | |
| text-transform: uppercase; | |
| color: #6b7280; | |
| } | |
| .transport-btn { | |
| background: linear-gradient(to bottom, #ffffff, #f3f4f6); | |
| border: 1px solid #d1d5db; | |
| border-radius: 8px; | |
| padding: 12px 28px; | |
| font-size: 13px; | |
| font-weight: 600; | |
| color: #374151; | |
| cursor: pointer; | |
| box-shadow: 0 2px 4px rgba(0,0,0,0.06), inset 0 1px 0 white; | |
| transition: all 0.15s; | |
| } | |
| .transport-btn:hover { background: linear-gradient(to bottom, #ffffff, #e5e7eb); } | |
| .transport-btn:active { box-shadow: inset 0 2px 4px rgba(0,0,0,0.1); } | |
| .transport-btn.playing { | |
| background: linear-gradient(to bottom, #dcfce7, #bbf7d0); | |
| border-color: #86efac; | |
| color: #166534; | |
| } | |
| /* Device Rack */ | |
| .rack { | |
| display: grid; | |
| grid-template-columns: repeat(4, 1fr); | |
| gap: 12px; | |
| margin-bottom: 20px; | |
| } | |
| @media (max-width: 600px) { | |
| .rack { grid-template-columns: repeat(2, 1fr); } | |
| } | |
| .device { | |
| background: linear-gradient(to bottom, #fafafa, #f0f0f2); | |
| border: 1px solid #c8c8cc; | |
| border-radius: 12px; | |
| padding: 16px; | |
| box-shadow: 0 4px 12px rgba(0,0,0,0.08), inset 0 1px 0 white; | |
| } | |
| .device-header { | |
| display: flex; | |
| justify-content: space-between; | |
| align-items: center; | |
| margin-bottom: 16px; | |
| } | |
| .device-name { | |
| font-size: 11px; | |
| font-weight: 700; | |
| letter-spacing: 1px; | |
| text-transform: uppercase; | |
| color: #6b7280; | |
| } | |
| .device-led { | |
| width: 8px; | |
| height: 8px; | |
| border-radius: 50%; | |
| background: #9ca3af; | |
| box-shadow: inset 0 1px 2px rgba(0,0,0,0.2); | |
| transition: all 0.1s; | |
| } | |
| .device-led.on { | |
| background: #4ade80; | |
| box-shadow: 0 0 8px #4ade80, inset 0 1px 2px rgba(255,255,255,0.3); | |
| } | |
| .device-controls { | |
| display: flex; | |
| flex-direction: column; | |
| align-items: center; | |
| gap: 12px; | |
| } | |
| /* Knob */ | |
| .knob-container { | |
| display: flex; | |
| flex-direction: column; | |
| align-items: center; | |
| gap: 8px; | |
| } | |
| .knob { | |
| width: 56px; | |
| height: 56px; | |
| border-radius: 50%; | |
| background: linear-gradient(145deg, #e8e8ea, #d0d0d4); | |
| box-shadow: 0 4px 8px rgba(0,0,0,0.15), inset 0 2px 4px rgba(255,255,255,0.8); | |
| position: relative; | |
| cursor: pointer; | |
| transition: box-shadow 0.15s; | |
| } | |
| .knob::before { | |
| content: ''; | |
| position: absolute; | |
| inset: 4px; | |
| border-radius: 50%; | |
| background: linear-gradient(to bottom, #f8f8fa, #e8e8ec); | |
| box-shadow: inset 0 2px 4px rgba(0,0,0,0.1); | |
| } | |
| .knob.active { | |
| box-shadow: 0 4px 8px rgba(0,0,0,0.15), 0 0 0 4px rgba(59, 130, 246, 0.4), inset 0 2px 4px rgba(255,255,255,0.8); | |
| } | |
| .knob-indicator { | |
| position: absolute; | |
| width: 3px; | |
| height: 12px; | |
| background: #374151; | |
| border-radius: 2px; | |
| left: 50%; | |
| top: 8px; | |
| transform-origin: center 20px; | |
| transform: translateX(-50%) rotate(-135deg); | |
| z-index: 1; | |
| } | |
| .knob-label { | |
| font-size: 9px; | |
| font-weight: 600; | |
| letter-spacing: 0.5px; | |
| text-transform: uppercase; | |
| color: #9ca3af; | |
| } | |
| .knob-value { | |
| font-size: 11px; | |
| font-weight: 600; | |
| color: #6b7280; | |
| font-variant-numeric: tabular-nums; | |
| } | |
| /* Trigger Button */ | |
| .trigger-btn { | |
| width: 100%; | |
| padding: 14px 16px; | |
| background: linear-gradient(to bottom, #fef3c7, #fde68a); | |
| border: 1px solid #f59e0b; | |
| border-radius: 8px; | |
| font-size: 11px; | |
| font-weight: 700; | |
| letter-spacing: 1px; | |
| text-transform: uppercase; | |
| color: #92400e; | |
| cursor: pointer; | |
| box-shadow: 0 2px 4px rgba(0,0,0,0.1), inset 0 1px 0 rgba(255,255,255,0.5); | |
| transition: all 0.1s; | |
| } | |
| .trigger-btn:active, .trigger-btn.active { | |
| background: linear-gradient(to bottom, #fde68a, #fcd34d); | |
| box-shadow: inset 0 2px 4px rgba(0,0,0,0.15); | |
| transform: translateY(1px); | |
| } | |
| /* Sequencer */ | |
| .sequencer { | |
| background: linear-gradient(to bottom, #27272a, #18181b); | |
| border: 1px solid #3f3f46; | |
| border-radius: 12px; | |
| padding: 16px; | |
| margin-bottom: 20px; | |
| box-shadow: 0 4px 12px rgba(0,0,0,0.2), inset 0 1px 0 rgba(255,255,255,0.05); | |
| } | |
| .seq-row { | |
| display: flex; | |
| align-items: center; | |
| gap: 8px; | |
| margin-bottom: 8px; | |
| } | |
| .seq-row:last-child { margin-bottom: 0; } | |
| .seq-label { | |
| width: 50px; | |
| font-size: 9px; | |
| font-weight: 600; | |
| letter-spacing: 0.5px; | |
| text-transform: uppercase; | |
| color: #71717a; | |
| flex-shrink: 0; | |
| } | |
| .seq-steps { | |
| display: flex; | |
| gap: 4px; | |
| flex: 1; | |
| } | |
| .seq-step { | |
| flex: 1; | |
| height: 24px; | |
| background: #3f3f46; | |
| border-radius: 3px; | |
| transition: all 0.1s; | |
| position: relative; | |
| } | |
| .seq-step.active { | |
| background: #52525b; | |
| box-shadow: inset 0 0 0 1px rgba(255,255,255,0.1); | |
| } | |
| .seq-step.current { | |
| box-shadow: inset 0 0 0 2px #3b82f6; | |
| } | |
| .seq-step.note { | |
| background: linear-gradient(to bottom, #4ade80, #22c55e); | |
| box-shadow: 0 0 8px rgba(74, 222, 128, 0.4); | |
| } | |
| .seq-step.note.pad { background: linear-gradient(to bottom, #818cf8, #6366f1); box-shadow: 0 0 8px rgba(129, 140, 248, 0.4); } | |
| .seq-step.note.clank { background: linear-gradient(to bottom, #fbbf24, #f59e0b); box-shadow: 0 0 8px rgba(251, 191, 36, 0.4); } | |
| .seq-step.note.melody { background: linear-gradient(to bottom, #f472b6, #ec4899); box-shadow: 0 0 8px rgba(244, 114, 182, 0.4); } | |
| .seq-beat-marker { | |
| position: absolute; | |
| bottom: -4px; | |
| left: 50%; | |
| transform: translateX(-50%); | |
| width: 4px; | |
| height: 4px; | |
| background: #52525b; | |
| border-radius: 50%; | |
| } | |
| .seq-step:nth-child(4n+1) .seq-beat-marker { background: #71717a; } | |
| /* Mixer */ | |
| .mixer { | |
| background: linear-gradient(to bottom, #fafafa, #f0f0f2); | |
| border: 1px solid #c8c8cc; | |
| border-radius: 12px; | |
| padding: 20px; | |
| box-shadow: 0 4px 12px rgba(0,0,0,0.08), inset 0 1px 0 white; | |
| } | |
| .mixer-title { | |
| font-size: 10px; | |
| font-weight: 700; | |
| letter-spacing: 1px; | |
| text-transform: uppercase; | |
| color: #9ca3af; | |
| margin-bottom: 16px; | |
| } | |
| .mixer-channels { | |
| display: grid; | |
| grid-template-columns: repeat(4, 1fr); | |
| gap: 16px; | |
| } | |
| .channel { | |
| display: flex; | |
| flex-direction: column; | |
| align-items: center; | |
| gap: 12px; | |
| } | |
| .channel-name { | |
| font-size: 10px; | |
| font-weight: 700; | |
| letter-spacing: 0.5px; | |
| text-transform: uppercase; | |
| color: #6b7280; | |
| } | |
| .fader-track { | |
| width: 32px; | |
| height: 120px; | |
| background: linear-gradient(to bottom, #e5e7eb, #d1d5db); | |
| border-radius: 4px; | |
| position: relative; | |
| box-shadow: inset 0 2px 4px rgba(0,0,0,0.15); | |
| cursor: pointer; | |
| } | |
| .fader-track.active { | |
| box-shadow: inset 0 2px 4px rgba(0,0,0,0.15), 0 0 0 3px rgba(59, 130, 246, 0.4); | |
| } | |
| .fader-fill { | |
| position: absolute; | |
| bottom: 0; | |
| left: 0; | |
| right: 0; | |
| background: linear-gradient(to top, #3b82f6, #60a5fa); | |
| border-radius: 0 0 4px 4px; | |
| transition: height 0.05s; | |
| } | |
| .fader-handle { | |
| position: absolute; | |
| left: -6px; | |
| right: -6px; | |
| height: 20px; | |
| background: linear-gradient(to bottom, #ffffff, #e5e7eb); | |
| border: 1px solid #9ca3af; | |
| border-radius: 4px; | |
| box-shadow: 0 2px 4px rgba(0,0,0,0.15); | |
| transform: translateY(50%); | |
| } | |
| .fader-db { | |
| font-size: 10px; | |
| font-weight: 600; | |
| color: #9ca3af; | |
| font-variant-numeric: tabular-nums; | |
| } | |
| .channel-buttons { | |
| display: flex; | |
| gap: 6px; | |
| } | |
| .mute-btn, .solo-btn { | |
| width: 28px; | |
| height: 28px; | |
| border-radius: 4px; | |
| border: 1px solid #d1d5db; | |
| background: linear-gradient(to bottom, #ffffff, #f3f4f6); | |
| font-size: 10px; | |
| font-weight: 700; | |
| cursor: pointer; | |
| box-shadow: 0 1px 2px rgba(0,0,0,0.05); | |
| transition: all 0.1s; | |
| } | |
| .mute-btn { color: #9ca3af; } | |
| .solo-btn { color: #9ca3af; } | |
| .mute-btn.active { | |
| background: linear-gradient(to bottom, #fca5a5, #f87171); | |
| border-color: #ef4444; | |
| color: #7f1d1d; | |
| } | |
| .solo-btn.active { | |
| background: linear-gradient(to bottom, #fde047, #facc15); | |
| border-color: #eab308; | |
| color: #713f12; | |
| } | |
| .mute-btn:active, .solo-btn:active { | |
| box-shadow: inset 0 2px 4px rgba(0,0,0,0.1); | |
| } | |
| /* Meter */ | |
| .meter { | |
| width: 6px; | |
| height: 120px; | |
| background: #27272a; | |
| border-radius: 3px; | |
| position: relative; | |
| overflow: hidden; | |
| } | |
| .meter-fill { | |
| position: absolute; | |
| bottom: 0; | |
| left: 0; | |
| right: 0; | |
| background: linear-gradient(to top, #22c55e, #4ade80, #fbbf24, #ef4444); | |
| transition: height 0.05s; | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| <div class="container"> | |
| <header> | |
| <h1>Ambient Synth Rack</h1> | |
| <button class="transport-btn" id="transport">▶ START</button> | |
| </header> | |
| <div class="rack"> | |
| <div class="device" id="dev-arp"> | |
| <div class="device-header"> | |
| <span class="device-name">Arpeggio</span> | |
| <div class="device-led" id="led-arp"></div> | |
| </div> | |
| <div class="device-controls"> | |
| <div class="knob-container"> | |
| <div class="knob" id="knob-arp-filter"> | |
| <div class="knob-indicator"></div> | |
| </div> | |
| <span class="knob-label">Filter</span> | |
| <span class="knob-value" id="val-arp-filter">2.4k</span> | |
| </div> | |
| <div class="knob-container"> | |
| <div class="knob" id="knob-arp-res"> | |
| <div class="knob-indicator"></div> | |
| </div> | |
| <span class="knob-label">Resonance</span> | |
| <span class="knob-value" id="val-arp-res">4.0</span> | |
| </div> | |
| </div> | |
| </div> | |
| <div class="device" id="dev-pad"> | |
| <div class="device-header"> | |
| <span class="device-name">Pad</span> | |
| <div class="device-led" id="led-pad"></div> | |
| </div> | |
| <div class="device-controls"> | |
| <div class="knob-container"> | |
| <div class="knob" id="knob-pad-filter"> | |
| <div class="knob-indicator"></div> | |
| </div> | |
| <span class="knob-label">Warmth</span> | |
| <span class="knob-value" id="val-pad-filter">1.2k</span> | |
| </div> | |
| <div class="knob-container"> | |
| <div class="knob" id="knob-pad-breath"> | |
| <div class="knob-indicator"></div> | |
| </div> | |
| <span class="knob-label">Breath</span> | |
| <span class="knob-value" id="val-pad-breath">0.08</span> | |
| </div> | |
| </div> | |
| </div> | |
| <div class="device" id="dev-clank"> | |
| <div class="device-header"> | |
| <span class="device-name">Clanks</span> | |
| <div class="device-led" id="led-clank"></div> | |
| </div> | |
| <div class="device-controls"> | |
| <button class="trigger-btn" id="clank-trigger">⚡ Trigger</button> | |
| <div class="knob-container"> | |
| <div class="knob" id="knob-clank-density"> | |
| <div class="knob-indicator"></div> | |
| </div> | |
| <span class="knob-label">Density</span> | |
| <span class="knob-value" id="val-clank-density">35%</span> | |
| </div> | |
| </div> | |
| </div> | |
| <div class="device" id="dev-melody"> | |
| <div class="device-header"> | |
| <span class="device-name">Melody</span> | |
| <div class="device-led" id="led-melody"></div> | |
| </div> | |
| <div class="device-controls"> | |
| <div class="knob-container"> | |
| <div class="knob" id="knob-melody-slide"> | |
| <div class="knob-indicator"></div> | |
| </div> | |
| <span class="knob-label">Slide</span> | |
| <span class="knob-value" id="val-melody-slide">0.25s</span> | |
| </div> | |
| <div class="knob-container"> | |
| <div class="knob" id="knob-melody-vibrato"> | |
| <div class="knob-indicator"></div> | |
| </div> | |
| <span class="knob-label">Vibrato</span> | |
| <span class="knob-value" id="val-melody-vibrato">4.5</span> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <div class="sequencer" id="sequencer"> | |
| <div class="seq-row"> | |
| <span class="seq-label">Arp</span> | |
| <div class="seq-steps" id="seq-arp"></div> | |
| </div> | |
| <div class="seq-row"> | |
| <span class="seq-label">Pad</span> | |
| <div class="seq-steps" id="seq-pad"></div> | |
| </div> | |
| <div class="seq-row"> | |
| <span class="seq-label">Clanks</span> | |
| <div class="seq-steps" id="seq-clank"></div> | |
| </div> | |
| <div class="seq-row"> | |
| <span class="seq-label">Melody</span> | |
| <div class="seq-steps" id="seq-melody"></div> | |
| </div> | |
| </div> | |
| <div class="mixer"> | |
| <div class="mixer-title">Mixer</div> | |
| <div class="mixer-channels"> | |
| <div class="channel" data-ch="arp"> | |
| <span class="channel-name">Arp</span> | |
| <div style="display:flex;gap:8px;align-items:flex-end;"> | |
| <div class="fader-track" id="fader-arp"> | |
| <div class="fader-fill"></div> | |
| <div class="fader-handle"></div> | |
| </div> | |
| <div class="meter"><div class="meter-fill" id="meter-arp"></div></div> | |
| </div> | |
| <span class="fader-db" id="db-arp">0.0 dB</span> | |
| <div class="channel-buttons"> | |
| <button class="mute-btn" data-ch="arp">M</button> | |
| <button class="solo-btn" data-ch="arp">S</button> | |
| </div> | |
| </div> | |
| <div class="channel" data-ch="pad"> | |
| <span class="channel-name">Pad</span> | |
| <div style="display:flex;gap:8px;align-items:flex-end;"> | |
| <div class="fader-track" id="fader-pad"> | |
| <div class="fader-fill"></div> | |
| <div class="fader-handle"></div> | |
| </div> | |
| <div class="meter"><div class="meter-fill" id="meter-pad"></div></div> | |
| </div> | |
| <span class="fader-db" id="db-pad">0.0 dB</span> | |
| <div class="channel-buttons"> | |
| <button class="mute-btn" data-ch="pad">M</button> | |
| <button class="solo-btn" data-ch="pad">S</button> | |
| </div> | |
| </div> | |
| <div class="channel" data-ch="clank"> | |
| <span class="channel-name">Clank</span> | |
| <div style="display:flex;gap:8px;align-items:flex-end;"> | |
| <div class="fader-track" id="fader-clank"> | |
| <div class="fader-fill"></div> | |
| <div class="fader-handle"></div> | |
| </div> | |
| <div class="meter"><div class="meter-fill" id="meter-clank"></div></div> | |
| </div> | |
| <span class="fader-db" id="db-clank">0.0 dB</span> | |
| <div class="channel-buttons"> | |
| <button class="mute-btn" data-ch="clank">M</button> | |
| <button class="solo-btn" data-ch="clank">S</button> | |
| </div> | |
| </div> | |
| <div class="channel" data-ch="melody"> | |
| <span class="channel-name">Melody</span> | |
| <div style="display:flex;gap:8px;align-items:flex-end;"> | |
| <div class="fader-track" id="fader-melody"> | |
| <div class="fader-fill"></div> | |
| <div class="fader-handle"></div> | |
| </div> | |
| <div class="meter"><div class="meter-fill" id="meter-melody"></div></div> | |
| </div> | |
| <span class="fader-db" id="db-melody">0.0 dB</span> | |
| <div class="channel-buttons"> | |
| <button class="mute-btn" data-ch="melody">M</button> | |
| <button class="solo-btn" data-ch="melody">S</button> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <script> | |
| // ==================== CONSTANTS ==================== | |
| const BPM = 60; | |
| const STEP = 60 / BPM / 4; | |
| const ROOT = 50; | |
| const SCALE = [0, 2, 3, 5, 7, 9, 10, 12, 14, 15, 17, 19, 21, 24]; | |
| const CHORDS = [ | |
| [0, 3, 7, 10, 14], | |
| [5, 8, 12, 15, 19], | |
| [3, 7, 10, 14, 17], | |
| [7, 10, 14, 17, 21], | |
| [-2, 2, 5, 10, 14] | |
| ]; | |
| const mtof = m => 440 * Math.pow(2, (m - 69) / 12); | |
| // ==================== UI CONTROLS ==================== | |
| class Knob { | |
| constructor(el, opts) { | |
| this.el = el; | |
| this.indicator = el.querySelector('.knob-indicator'); | |
| this.min = opts.min ?? 0; | |
| this.max = opts.max ?? 1; | |
| this.value = opts.initial ?? 0.5; | |
| this.onChange = opts.onChange; | |
| this.format = opts.format || (v => v.toFixed(2)); | |
| this.valueEl = opts.valueEl; | |
| this.dragging = false; | |
| this.startY = 0; | |
| this.startVal = 0; | |
| el.addEventListener('mousedown', e => this.start(e)); | |
| el.addEventListener('touchstart', e => this.start(e), { passive: false }); | |
| document.addEventListener('mousemove', e => this.move(e)); | |
| document.addEventListener('touchmove', e => this.move(e), { passive: false }); | |
| document.addEventListener('mouseup', () => this.end()); | |
| document.addEventListener('touchend', () => this.end()); | |
| this.render(); | |
| } | |
| start(e) { | |
| e.preventDefault(); | |
| this.dragging = true; | |
| this.startY = e.clientY ?? e.touches[0].clientY; | |
| this.startVal = this.value; | |
| this.el.classList.add('active'); | |
| } | |
| move(e) { | |
| if (!this.dragging) return; | |
| e.preventDefault(); | |
| const y = e.clientY ?? e.touches[0].clientY; | |
| const delta = (this.startY - y) / 150; | |
| this.value = Math.max(this.min, Math.min(this.max, this.startVal + delta * (this.max - this.min))); | |
| this.render(); | |
| this.onChange?.(this.value); | |
| } | |
| end() { | |
| if (!this.dragging) return; | |
| this.dragging = false; | |
| this.el.classList.remove('active'); | |
| } | |
| render() { | |
| const norm = (this.value - this.min) / (this.max - this.min); | |
| const rot = -135 + norm * 270; | |
| this.indicator.style.transform = `translateX(-50%) rotate(${rot}deg)`; | |
| if (this.valueEl) this.valueEl.textContent = this.format(this.value); | |
| } | |
| setValue(v) { | |
| this.value = Math.max(this.min, Math.min(this.max, v)); | |
| this.render(); | |
| } | |
| } | |
| class Fader { | |
| constructor(el, opts) { | |
| this.el = el; | |
| this.fill = el.querySelector('.fader-fill'); | |
| this.handle = el.querySelector('.fader-handle'); | |
| this.min = opts.min ?? 0; | |
| this.max = opts.max ?? 1; | |
| this.value = opts.initial ?? 0.8; | |
| this.onChange = opts.onChange; | |
| this.dbEl = opts.dbEl; | |
| this.dragging = false; | |
| this.trackHeight = 120; | |
| el.addEventListener('mousedown', e => this.start(e)); | |
| el.addEventListener('touchstart', e => this.start(e), { passive: false }); | |
| document.addEventListener('mousemove', e => this.move(e)); | |
| document.addEventListener('touchmove', e => this.move(e), { passive: false }); | |
| document.addEventListener('mouseup', () => this.end()); | |
| document.addEventListener('touchend', () => this.end()); | |
| this.render(); | |
| } | |
| start(e) { | |
| e.preventDefault(); | |
| this.dragging = true; | |
| this.el.classList.add('active'); | |
| this.move(e); | |
| } | |
| move(e) { | |
| if (!this.dragging) return; | |
| e.preventDefault(); | |
| const rect = this.el.getBoundingClientRect(); | |
| const y = e.clientY ?? e.touches[0].clientY; | |
| const norm = 1 - Math.max(0, Math.min(1, (y - rect.top) / rect.height)); | |
| this.value = this.min + norm * (this.max - this.min); | |
| this.render(); | |
| this.onChange?.(this.value); | |
| } | |
| end() { | |
| if (!this.dragging) return; | |
| this.dragging = false; | |
| this.el.classList.remove('active'); | |
| } | |
| render() { | |
| const norm = (this.value - this.min) / (this.max - this.min); | |
| const pct = norm * 100; | |
| this.fill.style.height = pct + '%'; | |
| this.handle.style.bottom = `calc(${pct}% - 10px)`; | |
| if (this.dbEl) { | |
| const db = this.value > 0.001 ? 20 * Math.log10(this.value) : -60; | |
| this.dbEl.textContent = (db > -60 ? db.toFixed(1) : '-∞') + ' dB'; | |
| } | |
| } | |
| } | |
| // ==================== SEQUENCER UI ==================== | |
| function initSequencer() { | |
| ['arp', 'pad', 'clank', 'melody'].forEach(track => { | |
| const container = document.getElementById(`seq-${track}`); | |
| for (let i = 0; i < 16; i++) { | |
| const step = document.createElement('div'); | |
| step.className = 'seq-step'; | |
| step.dataset.step = i; | |
| const marker = document.createElement('div'); | |
| marker.className = 'seq-beat-marker'; | |
| step.appendChild(marker); | |
| container.appendChild(step); | |
| } | |
| }); | |
| } | |
| function updateSequencer(track, step, hasNote) { | |
| const container = document.getElementById(`seq-${track}`); | |
| if (!container) return; | |
| const steps = container.querySelectorAll('.seq-step'); | |
| steps.forEach((s, i) => { | |
| s.classList.remove('current'); | |
| if (i === step) s.classList.add('current'); | |
| }); | |
| if (hasNote && steps[step]) { | |
| steps[step].classList.add('note', track); | |
| setTimeout(() => steps[step].classList.remove('note', track), STEP * 900); | |
| } | |
| } | |
| function flashLed(id) { | |
| const led = document.getElementById(id); | |
| if (!led) return; | |
| led.classList.add('on'); | |
| setTimeout(() => led.classList.remove('on'), 100); | |
| } | |
| // ==================== AUDIO ENGINE ==================== | |
| class AmbientEngine { | |
| constructor() { | |
| this.ctx = null; | |
| this.playing = false; | |
| this.params = { | |
| arpFilter: 2400, | |
| arpRes: 4, | |
| padFilter: 1200, | |
| padBreath: 0.08, | |
| clankDensity: 0.035, | |
| melodySlide: 0.25, | |
| melodyVibrato: 4.5 | |
| }; | |
| this.mixer = { | |
| arp: { vol: 0.8, mute: false, solo: false }, | |
| pad: { vol: 0.8, mute: false, solo: false }, | |
| clank: { vol: 0.8, mute: false, solo: false }, | |
| melody: { vol: 0.8, mute: false, solo: false } | |
| }; | |
| this.meters = { arp: 0, pad: 0, clank: 0, melody: 0 }; | |
| } | |
| async init() { | |
| this.ctx = new (window.AudioContext || window.webkitAudioContext)(); | |
| const c = this.ctx; | |
| // Master chain | |
| this.master = c.createGain(); | |
| this.master.gain.value = 0.7; | |
| this.saturator = c.createWaveShaper(); | |
| this.saturator.curve = this.makeSaturationCurve(0.4); | |
| this.masterFilter = c.createBiquadFilter(); | |
| this.masterFilter.type = 'lowpass'; | |
| this.masterFilter.frequency.value = 8000; | |
| this.comp = c.createDynamicsCompressor(); | |
| this.comp.threshold.value = -20; | |
| this.comp.knee.value = 20; | |
| this.comp.ratio.value = 4; | |
| this.comp.attack.value = 0.01; | |
| this.comp.release.value = 0.3; | |
| // Reverb | |
| this.reverb = this.createReverb(4.5, 2.2); | |
| this.reverbReturn = c.createGain(); | |
| this.reverbReturn.gain.value = 0.5; | |
| // Delay | |
| this.delay = c.createDelay(3); | |
| this.delay.delayTime.value = STEP * 3; | |
| this.delayFeedback = c.createGain(); | |
| this.delayFeedback.gain.value = 0.4; | |
| this.delayFilter = c.createBiquadFilter(); | |
| this.delayFilter.type = 'lowpass'; | |
| this.delayFilter.frequency.value = 1800; | |
| // Channel strips | |
| this.channels = {}; | |
| ['arp', 'pad', 'clank', 'melody'].forEach(name => { | |
| const ch = { | |
| input: c.createGain(), | |
| fader: c.createGain(), | |
| output: c.createGain(), | |
| reverbSend: c.createGain(), | |
| delaySend: c.createGain(), | |
| analyser: c.createAnalyser() | |
| }; | |
| ch.analyser.fftSize = 256; | |
| ch.input.connect(ch.fader); | |
| ch.fader.connect(ch.output); | |
| ch.fader.connect(ch.analyser); | |
| ch.output.connect(this.master); | |
| ch.fader.connect(ch.reverbSend); | |
| ch.reverbSend.connect(this.reverb); | |
| ch.reverbSend.gain.value = name === 'clank' ? 0.4 : 0.2; | |
| ch.fader.connect(ch.delaySend); | |
| ch.delaySend.connect(this.delay); | |
| ch.delaySend.gain.value = name === 'arp' ? 0.3 : 0.1; | |
| this.channels[name] = ch; | |
| }); | |
| // Routing | |
| this.master.connect(this.saturator); | |
| this.saturator.connect(this.masterFilter); | |
| this.masterFilter.connect(this.comp); | |
| this.reverb.connect(this.reverbReturn); | |
| this.reverbReturn.connect(this.comp); | |
| this.delay.connect(this.delayFilter); | |
| this.delayFilter.connect(this.delayFeedback); | |
| this.delayFeedback.connect(this.delay); | |
| this.delayFilter.connect(this.reverbReturn); | |
| this.comp.connect(c.destination); | |
| // Instruments | |
| this.arp = new Arpeggiator(c, this.channels.arp.input, this); | |
| this.pad = new BreathyPad(c, this.channels.pad.input, this); | |
| this.clanks = new IndustrialClanks(c, this.channels.clank.input, this); | |
| this.melody = new TriangleMelody(c, this.channels.melody.input, this); | |
| this.stepCount = 0; | |
| this.chordIndex = 0; | |
| this.nextTime = 0; | |
| this.updateAllChannels(); | |
| this.startMeters(); | |
| } | |
| makeSaturationCurve(amount) { | |
| const samples = 256; | |
| const curve = new Float32Array(samples); | |
| for (let i = 0; i < samples; i++) { | |
| const x = (i * 2) / samples - 1; | |
| curve[i] = (Math.PI + amount) * x / (Math.PI + amount * Math.abs(x)); | |
| } | |
| return curve; | |
| } | |
| createReverb(time, decay) { | |
| const c = this.ctx; | |
| const length = c.sampleRate * time; | |
| const impulse = c.createBuffer(2, length, c.sampleRate); | |
| for (let ch = 0; ch < 2; ch++) { | |
| const data = impulse.getChannelData(ch); | |
| for (let i = 0; i < length; i++) { | |
| const env = Math.pow(1 - i / length, decay); | |
| data[i] = (Math.random() * 2 - 1) * env; | |
| if (i < c.sampleRate * 0.1 && Math.random() > 0.98) { | |
| data[i] += (Math.random() * 2 - 1) * 0.5; | |
| } | |
| } | |
| } | |
| const conv = c.createConvolver(); | |
| conv.buffer = impulse; | |
| return conv; | |
| } | |
| updateChannel(name) { | |
| const m = this.mixer[name]; | |
| const ch = this.channels[name]; | |
| const anySolo = Object.values(this.mixer).some(x => x.solo); | |
| const shouldMute = m.mute || (anySolo && !m.solo); | |
| const gain = shouldMute ? 0 : m.vol; | |
| ch.output.gain.value = gain; | |
| ch.reverbSend.gain.value = shouldMute ? 0 : (name === 'clank' ? 0.4 : 0.2); | |
| ch.delaySend.gain.value = shouldMute ? 0 : (name === 'arp' ? 0.3 : 0.1); | |
| } | |
| updateAllChannels() { | |
| ['arp', 'pad', 'clank', 'melody'].forEach(n => this.updateChannel(n)); | |
| } | |
| startMeters() { | |
| const update = () => { | |
| ['arp', 'pad', 'clank', 'melody'].forEach(name => { | |
| const ch = this.channels[name]; | |
| const arr = new Uint8Array(ch.analyser.frequencyBinCount); | |
| ch.analyser.getByteFrequencyData(arr); | |
| const avg = arr.reduce((a, b) => a + b, 0) / arr.length; | |
| this.meters[name] = avg / 255; | |
| const el = document.getElementById(`meter-${name}`); | |
| if (el) el.style.height = (this.meters[name] * 100) + '%'; | |
| }); | |
| requestAnimationFrame(update); | |
| }; | |
| update(); | |
| } | |
| start() { | |
| if (!this.ctx) return; | |
| this.playing = true; | |
| this.nextTime = this.ctx.currentTime + 0.1; | |
| this.stepCount = 0; | |
| this.chordIndex = 0; | |
| this.arp.start(CHORDS[0]); | |
| this.melody.start(); | |
| this.pad.setChord(CHORDS[0].map(n => ROOT + n), this.ctx.currentTime); | |
| this.schedule(); | |
| } | |
| schedule() { | |
| if (!this.playing) return; | |
| while (this.nextTime < this.ctx.currentTime + 0.25) { | |
| const t = this.nextTime; | |
| const step = this.stepCount % 16; | |
| // Arpeggiator | |
| const arpNote = this.arp.step(t); | |
| updateSequencer('arp', step, arpNote); | |
| if (arpNote) flashLed('led-arp'); | |
| // Industrial clanks | |
| if (Math.random() < this.params.clankDensity) { | |
| this.clanks.play(t + Math.random() * STEP); | |
| updateSequencer('clank', step, true); | |
| flashLed('led-clank'); | |
| } else { | |
| updateSequencer('clank', step, false); | |
| } | |
| // Melody | |
| if (this.stepCount % 12 === 0 || (this.stepCount % 8 === 4 && Math.random() > 0.6)) { | |
| this.melody.playNote(t); | |
| updateSequencer('melody', step, true); | |
| flashLed('led-melody'); | |
| } else { | |
| updateSequencer('melody', step, false); | |
| } | |
| // Pad visualization | |
| updateSequencer('pad', step, this.stepCount % 16 < 2); | |
| if (this.stepCount % 48 === 0) flashLed('led-pad'); | |
| // Chord progression | |
| if (this.stepCount > 0 && this.stepCount % 48 === 0) { | |
| this.chordIndex = (this.chordIndex + 1) % CHORDS.length; | |
| const chord = CHORDS[this.chordIndex]; | |
| this.arp.setChord(chord); | |
| this.pad.setChord(chord.map(n => ROOT + n), t); | |
| } | |
| this.nextTime += STEP; | |
| this.stepCount++; | |
| } | |
| this.timer = setTimeout(() => this.schedule(), 50); | |
| } | |
| triggerClank() { | |
| if (this.ctx && this.playing) { | |
| this.clanks.play(this.ctx.currentTime); | |
| flashLed('led-clank'); | |
| } | |
| } | |
| stop() { | |
| this.playing = false; | |
| clearTimeout(this.timer); | |
| this.melody.stop(); | |
| this.pad.fadeOut(); | |
| } | |
| } | |
| // ==================== INSTRUMENTS ==================== | |
| class Arpeggiator { | |
| constructor(ctx, output, engine) { | |
| this.ctx = ctx; | |
| this.engine = engine; | |
| this.out = ctx.createGain(); | |
| this.out.gain.value = 0.14; | |
| this.out.connect(output); | |
| this.pattern = []; | |
| this.idx = 0; | |
| this.chord = []; | |
| } | |
| start(chord) { | |
| this.setChord(chord); | |
| this.generatePattern(); | |
| } | |
| setChord(chord) { | |
| this.chord = chord; | |
| if (Math.random() > 0.5) this.generatePattern(); | |
| } | |
| generatePattern() { | |
| this.pattern = []; | |
| const extensions = [...this.chord, ...this.chord.map(n => n + 12), ...this.chord.map(n => n - 12)]; | |
| for (let i = 0; i < 16; i++) { | |
| if (Math.random() > 0.15) { | |
| const note = extensions[Math.floor(Math.random() * extensions.length)]; | |
| const velocity = 0.4 + Math.random() * 0.6; | |
| this.pattern.push({ note: ROOT + note, vel: velocity, dur: STEP * (1 + Math.floor(Math.random() * 3)) }); | |
| } else { | |
| this.pattern.push(null); | |
| } | |
| } | |
| } | |
| step(time) { | |
| const note = this.pattern[this.idx]; | |
| if (note) { | |
| this.playNote(note.note, time, note.dur, note.vel); | |
| } | |
| this.idx = (this.idx + 1) % this.pattern.length; | |
| if (this.idx === 0 && Math.random() > 0.6) { | |
| this.generatePattern(); | |
| } | |
| return !!note; | |
| } | |
| playNote(midi, time, dur, vel) { | |
| const c = this.ctx; | |
| const freq = mtof(midi); | |
| const noteOut = c.createGain(); | |
| noteOut.connect(this.out); | |
| const filt = c.createBiquadFilter(); | |
| filt.type = 'lowpass'; | |
| filt.Q.value = this.engine.params.arpRes; | |
| filt.frequency.setValueAtTime(this.engine.params.arpFilter, time); | |
| filt.frequency.exponentialRampToValueAtTime(400, time + dur * 0.9); | |
| filt.connect(noteOut); | |
| noteOut.gain.setValueAtTime(0, time); | |
| noteOut.gain.linearRampToValueAtTime(vel * 0.5, time + 0.008); | |
| noteOut.gain.exponentialRampToValueAtTime(vel * 0.2, time + dur * 0.3); | |
| noteOut.gain.exponentialRampToValueAtTime(0.001, time + dur); | |
| const detunes = [-8, -3, 0, 3, 8, 1200]; | |
| detunes.forEach((det, i) => { | |
| const osc = c.createOscillator(); | |
| osc.type = i < 4 ? 'sawtooth' : 'triangle'; | |
| osc.frequency.value = freq; | |
| osc.detune.value = det + (Math.random() - 0.5) * 4; | |
| const g = c.createGain(); | |
| g.gain.value = i < 4 ? 0.25 : 0.15; | |
| osc.connect(g); | |
| g.connect(filt); | |
| osc.start(time); | |
| osc.stop(time + dur + 0.1); | |
| }); | |
| } | |
| } | |
| class BreathyPad { | |
| constructor(ctx, output, engine) { | |
| this.ctx = ctx; | |
| this.engine = engine; | |
| this.out = ctx.createGain(); | |
| this.out.gain.value = 0.11; | |
| this.out.connect(output); | |
| this.voices = []; | |
| } | |
| setChord(notes, time) { | |
| this.voices.forEach(v => { | |
| v.gain.gain.setValueAtTime(v.gain.gain.value, time); | |
| v.gain.gain.exponentialRampToValueAtTime(0.001, time + 5); | |
| setTimeout(() => v.oscs.forEach(o => { try { o.stop(); } catch(e) {} }), 6000); | |
| }); | |
| this.voices = []; | |
| notes.forEach((midi, i) => this.voices.push(this.createVoice(midi, time, i))); | |
| } | |
| createVoice(midi, time, idx) { | |
| const c = this.ctx; | |
| const freq = mtof(midi); | |
| const vGain = c.createGain(); | |
| vGain.gain.setValueAtTime(0, time); | |
| vGain.gain.linearRampToValueAtTime(0.18, time + 3 + idx * 0.5); | |
| vGain.connect(this.out); | |
| const filt = c.createBiquadFilter(); | |
| filt.type = 'lowpass'; | |
| filt.frequency.value = this.engine.params.padFilter; | |
| filt.Q.value = 0.7; | |
| const lfo = c.createOscillator(); | |
| lfo.frequency.value = this.engine.params.padBreath + idx * 0.015; | |
| const lfoG = c.createGain(); | |
| lfoG.gain.value = 400; | |
| lfo.connect(lfoG); | |
| lfoG.connect(filt.frequency); | |
| lfo.start(time); | |
| const lfo2 = c.createOscillator(); | |
| lfo2.frequency.value = 0.05 + idx * 0.02; | |
| const pan = c.createStereoPanner(); | |
| const panLfoG = c.createGain(); | |
| panLfoG.gain.value = 0.4; | |
| lfo2.connect(panLfoG); | |
| panLfoG.connect(pan.pan); | |
| lfo2.start(time); | |
| filt.connect(pan); | |
| pan.connect(vGain); | |
| const oscs = [lfo, lfo2]; | |
| for (let i = 0; i < 6; i++) { | |
| const osc = c.createOscillator(); | |
| osc.type = i < 4 ? 'sawtooth' : 'triangle'; | |
| osc.frequency.value = freq; | |
| osc.detune.value = (i - 3) * 7 + Math.random() * 5; | |
| const g = c.createGain(); | |
| g.gain.value = i < 4 ? 0.18 : 0.12; | |
| osc.connect(g); | |
| g.connect(filt); | |
| osc.start(time); | |
| oscs.push(osc); | |
| } | |
| const sub = c.createOscillator(); | |
| sub.type = 'sine'; | |
| sub.frequency.value = freq / 2; | |
| const subG = c.createGain(); | |
| subG.gain.value = 0.15; | |
| sub.connect(subG); | |
| subG.connect(filt); | |
| sub.start(time); | |
| oscs.push(sub); | |
| return { oscs, gain: vGain, lfo, filter: filt }; | |
| } | |
| fadeOut() { | |
| const t = this.ctx.currentTime; | |
| this.voices.forEach(v => { | |
| v.gain.gain.setValueAtTime(v.gain.gain.value, t); | |
| v.gain.gain.exponentialRampToValueAtTime(0.001, t + 4); | |
| }); | |
| } | |
| updateParams() { | |
| this.voices.forEach(v => { | |
| if (v.filter) v.filter.frequency.value = this.engine.params.padFilter; | |
| if (v.lfo) v.lfo.frequency.value = this.engine.params.padBreath; | |
| }); | |
| } | |
| } | |
| class IndustrialClanks { | |
| constructor(ctx, output, engine) { | |
| this.ctx = ctx; | |
| this.engine = engine; | |
| this.out = ctx.createGain(); | |
| this.out.gain.value = 0.06; | |
| this.out.connect(output); | |
| } | |
| play(time) { | |
| const types = ['metal', 'thud', 'scrape', 'ring', 'clatter', 'pipe']; | |
| const type = types[Math.floor(Math.random() * types.length)]; | |
| this['_' + type](time); | |
| } | |
| _metal(time) { | |
| const c = this.ctx; | |
| const freq = 180 + Math.random() * 350; | |
| const car = c.createOscillator(); | |
| const mod = c.createOscillator(); | |
| const modG = c.createGain(); | |
| car.frequency.value = freq; | |
| mod.frequency.value = freq * (1.4 + Math.random() * 2.5); | |
| modG.gain.value = freq * (1 + Math.random() * 2); | |
| mod.connect(modG); | |
| modG.connect(car.frequency); | |
| const env = c.createGain(); | |
| env.gain.setValueAtTime(0.6, time); | |
| env.gain.exponentialRampToValueAtTime(0.001, time + 0.3); | |
| const hp = c.createBiquadFilter(); | |
| hp.type = 'highpass'; | |
| hp.frequency.value = 400 + Math.random() * 400; | |
| car.connect(hp); | |
| hp.connect(env); | |
| env.connect(this.out); | |
| car.start(time); | |
| mod.start(time); | |
| car.stop(time + 1); | |
| mod.stop(time + 1); | |
| } | |
| _thud(time) { | |
| const c = this.ctx; | |
| const osc = c.createOscillator(); | |
| osc.type = 'sine'; | |
| osc.frequency.setValueAtTime(80, time); | |
| osc.frequency.exponentialRampToValueAtTime(25, time + 0.25); | |
| const env = c.createGain(); | |
| env.gain.setValueAtTime(0.9, time); | |
| env.gain.exponentialRampToValueAtTime(0.001, time + 0.35); | |
| const noise = this.makeNoise(0.15); | |
| const nFilt = c.createBiquadFilter(); | |
| nFilt.type = 'lowpass'; | |
| nFilt.frequency.value = 150; | |
| const nEnv = c.createGain(); | |
| nEnv.gain.setValueAtTime(0.4, time); | |
| nEnv.gain.exponentialRampToValueAtTime(0.001, time + 0.12); | |
| noise.connect(nFilt); | |
| nFilt.connect(nEnv); | |
| nEnv.connect(this.out); | |
| osc.connect(env); | |
| env.connect(this.out); | |
| osc.start(time); | |
| noise.start(time); | |
| osc.stop(time + 0.5); | |
| noise.stop(time + 0.2); | |
| } | |
| _scrape(time) { | |
| const c = this.ctx; | |
| const dur = 0.4 + Math.random() * 0.8; | |
| const noise = this.makeNoise(dur); | |
| const filt = c.createBiquadFilter(); | |
| filt.type = 'bandpass'; | |
| filt.Q.value = 15 + Math.random() * 10; | |
| filt.frequency.setValueAtTime(2000 + Math.random() * 4000, time); | |
| filt.frequency.exponentialRampToValueAtTime(400 + Math.random() * 600, time + dur); | |
| const env = c.createGain(); | |
| env.gain.setValueAtTime(0, time); | |
| env.gain.linearRampToValueAtTime(0.35, time + 0.03); | |
| env.gain.exponentialRampToValueAtTime(0.001, time + dur); | |
| noise.connect(filt); | |
| filt.connect(env); | |
| env.connect(this.out); | |
| noise.start(time); | |
| noise.stop(time + dur + 0.1); | |
| } | |
| _ring(time) { | |
| const c = this.ctx; | |
| const dur = 2 + Math.random() * 4; | |
| const freqs = [200, 317, 438, 563, 712, 891].map(f => f * (0.8 + Math.random() * 0.4)); | |
| const master = c.createGain(); | |
| master.gain.setValueAtTime(0.25, time); | |
| master.gain.exponentialRampToValueAtTime(0.001, time + dur); | |
| master.connect(this.out); | |
| freqs.forEach((f, i) => { | |
| const osc = c.createOscillator(); | |
| osc.type = 'sine'; | |
| osc.frequency.value = f; | |
| const g = c.createGain(); | |
| g.gain.value = 0.3 / (i + 1); | |
| osc.connect(g); | |
| g.connect(master); | |
| osc.start(time); | |
| osc.stop(time + dur + 0.1); | |
| }); | |
| } | |
| _clatter(time) { | |
| const c = this.ctx; | |
| const hits = 3 + Math.floor(Math.random() * 5); | |
| for (let i = 0; i < hits; i++) { | |
| const t = time + i * (0.02 + Math.random() * 0.05); | |
| const freq = 300 + Math.random() * 600; | |
| const osc = c.createOscillator(); | |
| const osc2 = c.createOscillator(); | |
| osc.frequency.value = freq; | |
| osc2.frequency.value = freq * 1.7; | |
| const env = c.createGain(); | |
| env.gain.setValueAtTime(0.15, t); | |
| env.gain.exponentialRampToValueAtTime(0.001, t + 0.06); | |
| const hp = c.createBiquadFilter(); | |
| hp.type = 'highpass'; | |
| hp.frequency.value = 800; | |
| osc.connect(hp); | |
| osc2.connect(hp); | |
| hp.connect(env); | |
| env.connect(this.out); | |
| osc.start(t); | |
| osc2.start(t); | |
| osc.stop(t + 0.1); | |
| osc2.stop(t + 0.1); | |
| } | |
| } | |
| _pipe(time) { | |
| const c = this.ctx; | |
| const freq = 60 + Math.random() * 80; | |
| const dur = 1 + Math.random() * 2; | |
| const osc = c.createOscillator(); | |
| osc.type = 'sine'; | |
| osc.frequency.value = freq; | |
| const osc2 = c.createOscillator(); | |
| osc2.type = 'sine'; | |
| osc2.frequency.value = freq * 2.02; | |
| const filt = c.createBiquadFilter(); | |
| filt.type = 'bandpass'; | |
| filt.frequency.value = freq * 3; | |
| filt.Q.value = 5; | |
| const env = c.createGain(); | |
| env.gain.setValueAtTime(0.5, time); | |
| env.gain.exponentialRampToValueAtTime(0.3, time + 0.1); | |
| env.gain.exponentialRampToValueAtTime(0.001, time + dur); | |
| osc.connect(filt); | |
| osc2.connect(filt); | |
| filt.connect(env); | |
| env.connect(this.out); | |
| osc.start(time); | |
| osc2.start(time); | |
| osc.stop(time + dur + 0.1); | |
| osc2.stop(time + dur + 0.1); | |
| } | |
| makeNoise(dur) { | |
| const c = this.ctx; | |
| const buf = c.createBuffer(1, c.sampleRate * dur, c.sampleRate); | |
| const data = buf.getChannelData(0); | |
| for (let i = 0; i < data.length; i++) data[i] = Math.random() * 2 - 1; | |
| const src = c.createBufferSource(); | |
| src.buffer = buf; | |
| return src; | |
| } | |
| } | |
| class TriangleMelody { | |
| constructor(ctx, output, engine) { | |
| this.ctx = ctx; | |
| this.engine = engine; | |
| this.out = ctx.createGain(); | |
| this.out.gain.value = 0.09; | |
| this.out.connect(output); | |
| this.melody = []; | |
| this.idx = 0; | |
| this.osc = null; | |
| this.currentFreq = mtof(ROOT + 12); | |
| } | |
| generateMelody() { | |
| this.melody = []; | |
| const len = 6 + Math.floor(Math.random() * 6); | |
| for (let i = 0; i < len; i++) { | |
| if (Math.random() > 0.25) { | |
| const note = SCALE[Math.floor(Math.random() * 10)] + 12; | |
| this.melody.push(ROOT + note); | |
| } else { | |
| this.melody.push(null); | |
| } | |
| } | |
| } | |
| start() { | |
| const c = this.ctx; | |
| this.generateMelody(); | |
| this.osc = c.createOscillator(); | |
| this.osc.type = 'triangle'; | |
| this.osc.frequency.value = this.currentFreq; | |
| this.vib = c.createOscillator(); | |
| this.vib.frequency.value = this.engine.params.melodyVibrato; | |
| this.vibG = c.createGain(); | |
| this.vibG.gain.value = 4; | |
| this.vib.connect(this.vibG); | |
| this.vibG.connect(this.osc.frequency); | |
| this.vib.start(); | |
| this.filt = c.createBiquadFilter(); | |
| this.filt.type = 'lowpass'; | |
| this.filt.frequency.value = 2500; | |
| this.filt.Q.value = 1; | |
| this.oscGain = c.createGain(); | |
| this.oscGain.gain.value = 0; | |
| this.osc.connect(this.filt); | |
| this.filt.connect(this.oscGain); | |
| this.oscGain.connect(this.out); | |
| this.osc.start(); | |
| } | |
| playNote(time) { | |
| const note = this.melody[this.idx]; | |
| if (note !== null) { | |
| const freq = mtof(note); | |
| const slide = this.engine.params.melodySlide; | |
| this.osc.frequency.setValueAtTime(this.currentFreq, time); | |
| this.osc.frequency.linearRampToValueAtTime(freq, time + slide); | |
| this.currentFreq = freq; | |
| this.oscGain.gain.setValueAtTime(this.oscGain.gain.value, time); | |
| this.oscGain.gain.linearRampToValueAtTime(0.7, time + 0.4); | |
| this.oscGain.gain.linearRampToValueAtTime(0.25, time + 3); | |
| } else { | |
| this.oscGain.gain.setValueAtTime(this.oscGain.gain.value, time); | |
| this.oscGain.gain.linearRampToValueAtTime(0, time + 1.5); | |
| } | |
| this.idx = (this.idx + 1) % this.melody.length; | |
| if (this.idx === 0 && Math.random() > 0.4) this.generateMelody(); | |
| } | |
| updateParams() { | |
| if (this.vib) this.vib.frequency.value = this.engine.params.melodyVibrato; | |
| } | |
| stop() { | |
| if (!this.osc) return; | |
| const t = this.ctx.currentTime; | |
| this.oscGain.gain.setValueAtTime(this.oscGain.gain.value, t); | |
| this.oscGain.gain.linearRampToValueAtTime(0, t + 3); | |
| this.osc.stop(t + 3.5); | |
| this.vib.stop(t + 3.5); | |
| } | |
| } | |
| // ==================== INIT ==================== | |
| const engine = new AmbientEngine(); | |
| initSequencer(); | |
| // Transport | |
| const transportBtn = document.getElementById('transport'); | |
| transportBtn.addEventListener('click', async () => { | |
| if (!engine.ctx) await engine.init(); | |
| if (engine.ctx.state === 'suspended') await engine.ctx.resume(); | |
| if (engine.playing) { | |
| engine.stop(); | |
| transportBtn.textContent = '▶ START'; | |
| transportBtn.classList.remove('playing'); | |
| } else { | |
| engine.start(); | |
| transportBtn.textContent = '■ STOP'; | |
| transportBtn.classList.add('playing'); | |
| } | |
| }); | |
| // Knobs | |
| const knobs = { | |
| arpFilter: new Knob(document.getElementById('knob-arp-filter'), { | |
| min: 200, max: 8000, initial: 2400, | |
| valueEl: document.getElementById('val-arp-filter'), | |
| format: v => v >= 1000 ? (v/1000).toFixed(1) + 'k' : Math.round(v), | |
| onChange: v => { engine.params.arpFilter = v; } | |
| }), | |
| arpRes: new Knob(document.getElementById('knob-arp-res'), { | |
| min: 0.5, max: 15, initial: 4, | |
| valueEl: document.getElementById('val-arp-res'), | |
| format: v => v.toFixed(1), | |
| onChange: v => { engine.params.arpRes = v; } | |
| }), | |
| padFilter: new Knob(document.getElementById('knob-pad-filter'), { | |
| min: 200, max: 4000, initial: 1200, | |
| valueEl: document.getElementById('val-pad-filter'), | |
| format: v => v >= 1000 ? (v/1000).toFixed(1) + 'k' : Math.round(v), | |
| onChange: v => { engine.params.padFilter = v; engine.pad?.updateParams(); } | |
| }), | |
| padBreath: new Knob(document.getElementById('knob-pad-breath'), { | |
| min: 0.02, max: 0.3, initial: 0.08, | |
| valueEl: document.getElementById('val-pad-breath'), | |
| format: v => v.toFixed(2), | |
| onChange: v => { engine.params.padBreath = v; engine.pad?.updateParams(); } | |
| }), | |
| clankDensity: new Knob(document.getElementById('knob-clank-density'), { | |
| min: 0, max: 0.15, initial: 0.035, | |
| valueEl: document.getElementById('val-clank-density'), | |
| format: v => Math.round(v * 100 / 0.15) + '%', | |
| onChange: v => { engine.params.clankDensity = v; } | |
| }), | |
| melodySlide: new Knob(document.getElementById('knob-melody-slide'), { | |
| min: 0.05, max: 0.8, initial: 0.25, | |
| valueEl: document.getElementById('val-melody-slide'), | |
| format: v => v.toFixed(2) + 's', | |
| onChange: v => { engine.params.melodySlide = v; } | |
| }), | |
| melodyVibrato: new Knob(document.getElementById('knob-melody-vibrato'), { | |
| min: 1, max: 12, initial: 4.5, | |
| valueEl: document.getElementById('val-melody-vibrato'), | |
| format: v => v.toFixed(1), | |
| onChange: v => { engine.params.melodyVibrato = v; engine.melody?.updateParams(); } | |
| }) | |
| }; | |
| // Trigger button | |
| const triggerBtn = document.getElementById('clank-trigger'); | |
| triggerBtn.addEventListener('mousedown', () => { triggerBtn.classList.add('active'); engine.triggerClank(); }); | |
| triggerBtn.addEventListener('touchstart', e => { e.preventDefault(); triggerBtn.classList.add('active'); engine.triggerClank(); }); | |
| triggerBtn.addEventListener('mouseup', () => triggerBtn.classList.remove('active')); | |
| triggerBtn.addEventListener('touchend', () => triggerBtn.classList.remove('active')); | |
| triggerBtn.addEventListener('mouseleave', () => triggerBtn.classList.remove('active')); | |
| // Faders | |
| const faders = {}; | |
| ['arp', 'pad', 'clank', 'melody'].forEach(ch => { | |
| faders[ch] = new Fader(document.getElementById(`fader-${ch}`), { | |
| min: 0, max: 1.2, initial: 0.8, | |
| dbEl: document.getElementById(`db-${ch}`), | |
| onChange: v => { | |
| engine.mixer[ch].vol = v; | |
| engine.updateChannel(ch); | |
| } | |
| }); | |
| }); | |
| // Mute/Solo | |
| document.querySelectorAll('.mute-btn').forEach(btn => { | |
| btn.addEventListener('click', () => { | |
| const ch = btn.dataset.ch; | |
| engine.mixer[ch].mute = !engine.mixer[ch].mute; | |
| btn.classList.toggle('active', engine.mixer[ch].mute); | |
| engine.updateAllChannels(); | |
| }); | |
| }); | |
| document.querySelectorAll('.solo-btn').forEach(btn => { | |
| btn.addEventListener('click', () => { | |
| const ch = btn.dataset.ch; | |
| engine.mixer[ch].solo = !engine.mixer[ch].solo; | |
| btn.classList.toggle('active', engine.mixer[ch].solo); | |
| engine.updateAllChannels(); | |
| }); | |
| }); | |
| </script> | |
| </body> | |
| </html> |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment