A Pen by semanticentity on CodePen.
Created
October 15, 2025 02:10
-
-
Save semanticentity/a61c87d97d8cadf7c6fdc1cf69de3858 to your computer and use it in GitHub Desktop.
SEQUENCER:SYSTEM
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>SEQUENCER:SYSTEM</title> | |
| <link rel="preconnect" href="https://fonts.googleapis.com"> | |
| <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> | |
| <link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono&display=swap" rel="stylesheet"> | |
| <style> | |
| :root { | |
| --bg: #111; | |
| --panel-bg: #222; | |
| --text: #eee; | |
| --accent: #fff; | |
| --accent-dark: #aaa; | |
| --accent-secondary: #ccc; | |
| --accent-tertiary: #fff; | |
| --border-color: #444; | |
| --disabled-text: #666; | |
| --font-main: 'IBM Plex Mono', 'Roboto Mono', 'Consolas', 'Menlo', monospace; | |
| --solo-color: #38a; | |
| --mute-color: #a33; | |
| --darkred-color: rgb(85, 34, 34); | |
| } | |
| *, *::before, *::after { box-sizing: border-box; } | |
| body, html { | |
| margin: 0; padding: 0; width: 100%; height: 100%; | |
| background-color: var(--bg); color: var(--text); font-family: var(--font-main); | |
| font-size: 13px; overflow: hidden; user-select: none; | |
| } | |
| #app-container { | |
| display: grid; grid-template-columns: 420px 1fr; | |
| width: 100vw; height: 100vh; | |
| grid-template-rows: minmax(0, 1fr); | |
| } | |
| #controls { | |
| background-color: var(--bg); border-right: 1px solid var(--border-color); | |
| padding: 15px; overflow-y: auto; display: flex; | |
| flex-direction: column; gap: 15px; | |
| } | |
| #controls::-webkit-scrollbar { width: 8px; } | |
| #controls::-webkit-scrollbar-track { background: var(--panel-bg); } | |
| #controls::-webkit-scrollbar-thumb { background: var(--accent-dark); border-radius: 4px; } | |
| #controls::-webkit-scrollbar-thumb:hover { background: var(--accent); } | |
| #visualizer-container { | |
| background-color: #000; position: relative; | |
| display: grid; place-items: center; | |
| } | |
| #phase-clock { | |
| width: 90vmin; height: 90vmin; max-width: 800px; max-height: 800px; | |
| pointer-events: none; | |
| } | |
| h1, h2, h3 { | |
| font-weight: 400; margin: 0 0 10px 0; text-transform: uppercase; | |
| letter-spacing: 2px; border-bottom: 1px dotted var(--border-color); | |
| padding-bottom: 8px; | |
| font-family: var(--font-main); | |
| } | |
| h1 { | |
| font-size: 2.8em; text-align: center; color: var(--accent); | |
| border-bottom: 6px dotted var(--border-color); margin-bottom: -1px; | |
| } | |
| h1 span { color: var(--border-color); letter-spacing: 5px; } | |
| h2 { font-size: 1.5em; } | |
| h3 { | |
| font-size: 1.2em; | |
| color: var(--accent-secondary); | |
| position: relative; | |
| } | |
| .control-panel { | |
| background-color: var(--panel-bg); | |
| border: 1px dotted var(--border-color); | |
| padding: 10px 15px 15px; | |
| } | |
| .control-panel h3 { | |
| margin-top: 5px; | |
| } | |
| .control-panel.collapsible h3 { | |
| cursor: pointer; | |
| font-size: 1em; | |
| user-select: none; | |
| padding-right: 180px; /* Make space for RND controls and collapse btn */ | |
| } | |
| .control-panel.collapsible h3::after { | |
| content: "-"; | |
| position: absolute; | |
| right: 0px; | |
| top: 0; | |
| transform: translateY(-50%); | |
| color: var(--disabled-text); | |
| font-size: 0.9em; | |
| } | |
| .control-panel.collapsible.collapsed h3::after { | |
| content: "+"; | |
| } | |
| .control-panel.collapsible .panel-content { | |
| max-height: 1000px; | |
| overflow: hidden; | |
| transition: max-height 0.3s ease-out, padding-top 0.3s ease-out; | |
| padding-top: 15px; | |
| } | |
| .control-panel.collapsible.collapsed .panel-content { | |
| max-height: 0; | |
| padding-top: 0; | |
| } | |
| .control-panel.collapsible.collapsed h3 { | |
| margin-bottom: 0; | |
| } | |
| /* --- RND BUTTON & INPUT STYLES --- */ | |
| .panel-header-controls { | |
| position: absolute; | |
| top: 50%; | |
| right: 25px; /* Position next to collapse icon */ | |
| transform: translateY(-50%); | |
| display: flex; | |
| align-items: center; | |
| gap: 5px; | |
| z-index: 2; | |
| } | |
| .randomize-panel-btn { | |
| position: static; /* Override any old absolute positioning */ | |
| transform: none; | |
| flex-shrink: 0; | |
| padding: 2px 6px; | |
| font-size: 0.9em; | |
| letter-spacing: 0.5px; | |
| background: var(--panel-bg); | |
| border: 1px solid var(--border-color); | |
| color: var(--disabled-text); | |
| font-family: var(--font-main); | |
| text-transform: uppercase; | |
| cursor: pointer; | |
| transition: all 0.1s; | |
| } | |
| .randomize-panel-btn:hover { | |
| background-color: var(--accent); | |
| color: var(--bg); | |
| border-color: var(--accent); | |
| } | |
| .randomize-panel-btn.flashing { | |
| background-color: var(--accent); | |
| color: var(--bg); | |
| border-color: var(--accent); | |
| box-shadow: 0 0 8px var(--accent); | |
| } | |
| .randomize-interval-input { | |
| width: 30px; | |
| padding: 3px 4px; | |
| background: var(--bg); | |
| border: 1px solid var(--border-color); | |
| color: var(--text); | |
| font-family: var(--font-main); | |
| text-align: center; | |
| -moz-appearance: textfield; /* Hide arrows in Firefox */ | |
| } | |
| .randomize-interval-input::-webkit-outer-spin-button, | |
| .randomize-interval-input::-webkit-inner-spin-button { | |
| -webkit-appearance: none; /* Hide arrows in Chrome/Safari */ | |
| margin: 0; | |
| } | |
| /* --- END RND BUTTON STYLES --- */ | |
| .sub-header { | |
| font-size: 0.9em; color: var(--disabled-text); margin-bottom: 5px; text-align: center; letter-spacing: 1px; | |
| } | |
| .row { display: flex; align-items: center; gap: 10px; margin-bottom: 10px; } | |
| label { | |
| white-space: nowrap; flex-basis: 110px; text-align: right; | |
| margin-right: 5px; color: var(--disabled-text); font-size: 0.9em; | |
| } | |
| button { | |
| background: var(--panel-bg); color: var(--text); border: 1px solid var(--border-color); | |
| padding: 10px 15px; cursor: pointer; font-family: var(--font-main); | |
| font-size: 0.9em; | |
| text-transform: uppercase; letter-spacing: 1.5px; flex-grow: 1; | |
| transition: all 0.2s; | |
| } | |
| button:hover { | |
| border-color: var(--accent); | |
| color: var(--accent); | |
| } | |
| button.active, button.toggled { | |
| background: var(--accent); color: var(--bg); border-color: var(--accent); font-weight: 700; | |
| } | |
| button:disabled { background: #111; color: var(--disabled-text); cursor: not-allowed; } | |
| button#wipe-btn { | |
| background-color: var(--darkred-color); | |
| transition: background-color 0.3s ease-in-out; | |
| } | |
| #add-track-panel { | |
| background-color: #2a2a2a; | |
| border: 1px solid var(--border-color); | |
| max-height: 0; | |
| padding: 0; | |
| overflow: hidden; | |
| transition: max-height 0.3s ease-out, padding 0.3s ease-out; | |
| display: grid; | |
| grid-template-columns: 1fr 1fr 1fr; | |
| gap: 0px; | |
| } | |
| #add-track-panel.open { | |
| max-height: 90vh; | |
| padding: 15px 0; | |
| overflow-y: auto; | |
| box-shadow: 0 0 80px black; | |
| } | |
| #add-track-panel button { font-size: 0.9em; padding: 8px 4px; margin-bottom: 15px; } | |
| input[type="range"] { | |
| -webkit-appearance: none; width: 100%; height: 3px; | |
| background: var(--border-color); outline: none; flex-grow: 1; | |
| } | |
| input[type="range"]::-webkit-slider-thumb { | |
| -webkit-appearance: none; appearance: none; width: 12px; height: 22px; | |
| background: var(--accent); cursor: pointer; border: none; | |
| } | |
| input[type="checkbox"] { | |
| -webkit-appearance: none; | |
| appearance: none; | |
| position: relative; | |
| width: 44px; | |
| height: 24px; | |
| border-radius: 12px; | |
| background-color: var(--border-color); | |
| border: 1px solid var(--border-color); | |
| cursor: pointer; | |
| vertical-align: middle; | |
| transition: background-color 0.2s ease-in-out; | |
| margin: 0; | |
| flex-shrink: 0; | |
| flex-grow: 0; | |
| } | |
| input[type="checkbox"]::before { | |
| content: ""; | |
| position: absolute; | |
| top: 2px; | |
| left: 2px; | |
| width: 18px; | |
| height: 18px; | |
| border-radius: 50%; | |
| background-color: var(--bg); | |
| transition: transform 0.2s ease-in-out, background-color 0.2s ease-in-out; | |
| box-shadow: none; | |
| } | |
| input[type="checkbox"]:checked { | |
| background-color: var(--accent); | |
| border-color: var(--accent); | |
| } | |
| input[type="checkbox"]:checked::before { | |
| transform: translateX(20px); | |
| background-color: var(--bg); | |
| } | |
| .value-display { min-width: 40px; text-align: left; font-variant-numeric: tabular-nums; } | |
| #sequencer-grid { display: flex; flex-direction: column; gap: 5px; } | |
| #tracks-wrapper:not(.expanded) #sequencer-grid .track:nth-child(n+4) { | |
| display: none; | |
| } | |
| #toggle-tracks-btn { | |
| width: 100%; | |
| margin-top: 10px; | |
| padding: 8px; | |
| font-size: 0.9em; | |
| letter-spacing: 1px; | |
| display: none; | |
| } | |
| .track { | |
| display: grid; | |
| grid-template-columns: 155px 1fr 45px; | |
| align-items: center; | |
| gap: 3px; | |
| background: #191919; | |
| padding: 5px 3px; | |
| border-left: 3px solid var(--disabled-text); | |
| transition: all 0.2s; | |
| } | |
| .track.active { border-left-color: var(--accent-secondary); } | |
| .track.triggered-track { border-left-color: var(--accent); } | |
| .track.muted { opacity: 0.6; background: #111; } | |
| .track.soloed { border-left-color: var(--solo-color); } | |
| .track.inactive-solo { opacity: 0.5; } | |
| .track-controls { | |
| display: flex; | |
| gap: 2px; | |
| } | |
| .track-controls button { | |
| padding: 5px; font-size: 10px; flex-grow: 1; | |
| min-width: 0; | |
| } | |
| .track-controls .solo-btn.toggled { background-color: var(--solo-color); border-color: var(--solo-color); color: var(--bg); } | |
| .track-controls .mute-btn.toggled { background-color: var(--mute-color); border-color: var(--mute-color); color: var(--bg); } | |
| .track-controls .bypass-btn.toggled { | |
| background-color: #888; | |
| border-color: #888; | |
| color: var(--bg); | |
| } | |
| .track-controls .track-name-btn { flex-grow: 3; min-width: 40px; } | |
| .track-controls .solo-btn, .track-controls .mute-btn, .track-controls .bypass-btn, | |
| .track-controls .shuffle-params-btn, .track-controls .clear-params-btn, | |
| .track-controls .duplicate-btn, .track-controls .remove-track-btn { | |
| flex-grow: 0; flex-shrink: 0; width: 22px; padding: 5px 0; | |
| } | |
| .track-info { | |
| display: flex; | |
| flex-direction: column; | |
| gap: 1px; | |
| margin-left: 18px; | |
| font-size: 7.5px; | |
| color: var(--disabled-text); | |
| font-variant-numeric: tabular-nums; | |
| } | |
| .steps-container { | |
| display: flex; | |
| gap: 0; | |
| cursor: pointer; | |
| margin-left: 52px; width: 80%; | |
| } | |
| .step { | |
| flex: 1; width: 100%; height: 20px; background-color: var(--border-color); | |
| border: 1px solid #333; transition: background-color 0.1s; | |
| border-radius: 2px; | |
| } | |
| .step:hover { border-color: var(--accent); } | |
| .step.on { background-color: var(--accent-dark); } | |
| .step.current { border: 1px solid var(--accent-tertiary); box-shadow: 0 0 5px var(--accent-tertiary); } | |
| .step.triggered { animation: flash 0.15s linear; } | |
| .step.overridden { box-shadow: inset 0 0 0 1px var(--accent-secondary); } | |
| .step.modulated { | |
| box-shadow: inset 0 0 0 1px var(--accent-secondary); | |
| } | |
| /* --- ACCENT FEATURE --- */ | |
| .step.accented { | |
| border-bottom: 3px solid var(--accent); | |
| } | |
| /* --- END ACCENT FEATURE --- */ | |
| @keyframes flash { | |
| 0% { background-color: var(--accent); } 100% { background-color: var(--border-color); } | |
| } | |
| .step.on.triggered { animation: flash-on 0.15s linear; } | |
| @keyframes flash-on { | |
| 0% { background-color: var(--accent); } 100% { background-color: var(--accent-dark); } | |
| } | |
| .modal-overlay { | |
| display: none; position: fixed; z-index: 1000; left: 0; top: 0; | |
| width: 100%; height: 100%; background-color: rgba(0,0,0,0.8); | |
| justify-content: center; align-items: center; backdrop-filter: blur(5px); | |
| } | |
| /* --- FINAL MODAL STYLES --- */ | |
| .modal-content { | |
| background-color: var(--bg); | |
| border: 1px solid var(--border-color); | |
| width: 90%; max-width: 450px; max-height: 90vh; | |
| box-shadow: none; | |
| display: flex; | |
| flex-direction: column; | |
| padding: 0; | |
| overflow: hidden; | |
| } | |
| .modal-content h3#modal-title { | |
| padding: 20px 20px 10px 20px; | |
| flex-shrink: 0; | |
| } | |
| #modal-scroll-container { | |
| overflow-y: auto; | |
| flex-grow: 1; | |
| padding: 10px 20px; | |
| } | |
| #modal-scroll-container::-webkit-scrollbar { width: 8px; } | |
| #modal-scroll-container::-webkit-scrollbar-track { background: var(--panel-bg); } | |
| #modal-scroll-container::-webkit-scrollbar-thumb { background: var(--accent-dark); border-radius: 4px; } | |
| #modal-footer { | |
| flex-shrink: 0; | |
| padding: 15px 20px 20px 20px; | |
| background-color: var(--bg); | |
| border-top: 1px solid var(--border-color); | |
| } | |
| #modal-footer .row { | |
| margin: 0; | |
| } | |
| .modal-content select, .modal-content input, .modal-content textarea { | |
| background: var(--panel-bg); color: var(--text); border: 1px solid var(--border-color); | |
| padding: 8px; font-family: inherit; width: 100%; | |
| } | |
| .modal-content input[type="checkbox"] { | |
| width: 44px; /* Override width: 100% for checkboxes */ | |
| } | |
| #genre-preset-modal .modal-content { | |
| padding: 15px 20px; | |
| } | |
| .row label.stretch { | |
| flex-grow: 1; | |
| flex-basis: auto; | |
| text-align: left; | |
| white-space: normal; | |
| margin-right: 10px; | |
| } | |
| /* --- NEW: TOOLTIP STYLES --- */ | |
| #custom-tooltip { | |
| position: fixed; /* Use fixed positioning to place it relative to the viewport */ | |
| background-color: var(--accent); | |
| color: var(--bg); | |
| padding: 6px 10px; | |
| border-radius: 4px; | |
| font-family: var(--font-main); | |
| font-size: 0.9em; | |
| z-index: 1001; /* Above everything else */ | |
| pointer-events: none; /* IMPORTANT: So it doesn't interfere with mouse events */ | |
| white-space: pre-wrap; /* Allows for line breaks with \n */ | |
| opacity: 1; | |
| transition: opacity 0.15s ease-out; | |
| border: 1px solid var(--border-color); | |
| box-shadow: 0 4px 15px rgba(0,0,0,0.4); | |
| } | |
| #custom-tooltip.hidden { | |
| opacity: 0; | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| <div id="app-container"> | |
| <div id="controls"> | |
| <h1>SEQUENCER<span>:</span>SYSTEM</h1> | |
| <div class="control-panel"> | |
| <div style="display: flex; position:absolute; right: 5px; top: 15px; gap: 4px;"> | |
| <button class="randomize-panel-btn walk-panel-btn">WALK</button> | |
| <button class="randomize-panel-btn">RND</button> | |
| </div> | |
| <div class="row"> | |
| <button id="play-button">PLAY</button> | |
| <button id="toggle-add-track-button">+ TRACK</button> | |
| </div> | |
| <div class="row"> | |
| <button id="save-session-btn">SAVE SESSION</button> | |
| <button id="load-session-btn">LOAD SESSION</button> | |
| <button id="wipe-btn">WIPE</button> | |
| </div> | |
| <div id="add-track-panel"> | |
| <div class="sub-header" style="grid-column: 1 / -1; margin-bottom: 0;">RANDOMIZE</div> | |
| <button id="random-any-btn" style="grid-column: 1 / -1;">RANDOM [ANY]</button> | |
| <div class="sub-header" style="grid-column: 1 / -1; margin-bottom: 0;">DRUMS & PERC</div> | |
| <button data-preset="kick">Kick</button> | |
| <button data-preset="kick" data-genre="dubtechno909">909 Kick</button> | |
| <button data-preset="snare">Snare</button> | |
| <button data-preset="hats">Hats</button> | |
| <button data-preset="hats" data-genre="dubtechno909">909 Hats</button> | |
| <button data-preset="clap">Clap</button> | |
| <button data-preset="rimshot">Rimshot</button> | |
| <button data-preset="shaker">Shaker</button> | |
| <button data-preset="tomfill">Tom Fill</button> | |
| <button data-preset="funkMute">Funk Mute</button> | |
| <button data-preset="cowbell">Cowbell</button> | |
| <button data-preset="clave">Clave</button> | |
| <div class="sub-header" style="grid-column: 1 / -1; margin-bottom: 0;">BASS & MELODIC</div> | |
| <button data-preset="subbass">Sub Bass</button> | |
| <button data-preset="midbass">Mid Bass</button> | |
| <button data-preset="dubbass">Dub Bass</button> | |
| <button data-preset="wobblebass">Wobble Bass</button> | |
| <button data-preset="dubchord">Dub Chord</button> | |
| <button data-preset="funkStab">Funk Stab</button> | |
| <button data-preset="glassystab">Glassy Stab</button> | |
| <button data-preset="eskibass">Eski Bass</button> | |
| <button data-preset="lead">Lead Synth</button> | |
| <button data-preset="melodica">Melodica</button> | |
| <button data-preset="synthflute">Synth Flute</button> | |
| <button data-preset="vibraphone">Vibraphone</button> | |
| <div class="sub-header" style="grid-column: 1 / -1; margin-bottom: 0;">ATMOS & FX</div> | |
| <button data-preset="pad">Deep Pad</button> | |
| <button data-preset="shortpad">Short Pad</button> | |
| <button data-preset="noiseperc">Noise Perc</button> | |
| <button data-preset="reese">Reese Bass</button> | |
| <button data-preset="drychord">Dry Chord</button> | |
| <button data-preset="siren">Siren FX</button> | |
| <button data-preset="aggrosiren">Aggro Siren</button> | |
| <button data-preset="sfx_manualsweep">Manual Sweep</button> | |
| <button data-preset="sfx_tuningtone">Tuning Tone</button> | |
| <button data-preset="sfx_sweep">Sweep</button> | |
| <button data-preset="sfx_riser">Riser</button> | |
| <button data-preset="sfx_zap">Zap</button> | |
| <button data-preset="noise">Noise Stab</button> | |
| <button data-preset="dubguitarriff">Dub Lick</button> | |
| <button data-preset="synthfunklick">Funk Lick</button> | |
| <div id="user-presets-container" style="grid-column: 1 / -1; display: contents;"> | |
| <!-- User presets will be dynamically inserted here --> | |
| </div> | |
| </div><br> | |
| <div class="row"> | |
| <label>BPM</label> | |
| <input type="range" id="bpm-slider" min="60" max="180" value="120" step="1"> | |
| <span id="bpm-display" class="value-display">120</span> | |
| </div> | |
| <div class="row"> | |
| <label>VOLUME</label> | |
| <input type="range" id="volume-slider" min="0" max="1" value="0.7" step="0.01"> | |
| <span id="volume-display" class="value-display">0.70</span> | |
| </div> | |
| <div class="row"> | |
| <label for="swing-slider">SWING</label> | |
| <input type="range" id="swing-slider" min="0" max="0.8" value="0.1" step="0.01"> | |
| <span id="swing-display" class="value-display">10%</span> | |
| </div> | |
| </div> | |
| <div id="master-effects-panel" class="control-panel collapsible collapsed"> | |
| <h3>MASTER EFFECTS</h3> | |
| <div class="panel-content"> | |
| <div class="sub-header">TAPE EMULATION</div> | |
| <div class="row"> | |
| <label>DRIVE</label> | |
| <input type="range" id="saturation-amount" min="0" max="1" value="0" step="0.01"> | |
| </div> | |
| <div class="row"> | |
| <label>TONE</label> | |
| <input type="range" id="saturation-tone" min="200" max="8000" value="4000" step="1"> | |
| </div> | |
| <div class="row"> | |
| <label>TAPE AGE</label> | |
| <input type="range" id="tape-age" min="1000" max="22000" value="22000" step="100"> | |
| </div> | |
| <div class="row"> | |
| <label>WOW/FLUTTER</label> | |
| <input type="range" id="wow-flutter" min="0" max="0.5" value="0" step="0.01"> | |
| </div> | |
| <div class="sub-header" style="margin-top:15px;">SPATIAL</div> | |
| <div class="row"> | |
| <label>MOD FX</label> | |
| <button id="mod-fx-type" style="flex-grow:0.5; font-size: 0.8em; padding: 5px;">PHASER</button> | |
| <input type="range" id="mod-fx-amount" min="0" max="1" value="0" step="0.01"> | |
| </div> | |
| <div class="row"> | |
| <label>DELAY 1</label> | |
| <input type="range" id="delay-amount" min="0" max="0.8" value="0.4" step="0.01"> | |
| </div> | |
| <div class="row"> | |
| <label>PITCH DRIFT</label> | |
| <input type="range" id="delay-pitch-drift" min="0" max="20" value="0" step="0.1"> | |
| </div> | |
| <div class="row"> | |
| <label>DELAY LP CUT</label> | |
| <input type="range" id="delay-lpf" min="500" max="22000" value="16000" step="100"> | |
| </div> | |
| <div class="row"> | |
| <label>DELAY HP CUT</label> | |
| <input type="range" id="delay-hpf" min="20" max="4000" value="20" step="10"> | |
| </div> | |
| <div class="row"> | |
| <label>DELAY 2</label> | |
| <input type="range" id="delay2-amount" min="0" max="0.8" value="0.2" step="0.01"> | |
| </div> | |
| <div class="row"> | |
| <label>REVERB</label> | |
| <input type="range" id="reverb-amount" min="0" max="0.8" value="0.25" step="0.01"> | |
| </div> | |
| <div class="row"> | |
| <label>RVB PRE-DELAY</label> | |
| <input type="range" id="reverb-predelay-slider" min="0" max="0.2" value="0.02" step="0.001"> | |
| <span id="reverb-predelay-display" class="value-display">20ms</span> | |
| </div> | |
| <div class="sub-header" style="margin-top:15px;">MIX BUS</div> | |
| <div class="row"> | |
| <label>SIDECHAIN</label> | |
| <input type="range" id="sidechain-amount" min="0" max="1" value="0.5" step="0.01"> | |
| </div> | |
| <div class="row"> | |
| <label>FILTER</label> | |
| <input type="range" id="filter-cutoff" min="200" max="25000" value="8000" step="1"> | |
| </div> | |
| </div> | |
| </div> | |
| <div id="tracks-wrapper" class="expanded"> | |
| <div id="sequencer-grid"> | |
| <!-- Tracks will be dynamically inserted here --> | |
| </div> | |
| <button id="toggle-tracks-btn">SHOW LESS</button> | |
| </div> | |
| <div id="dub-sends-panel" class="control-panel collapsible collapsed"> | |
| <h3>DUB SENDS</h3> | |
| <div class="panel-content"> | |
| <div class="row" style="margin-bottom: 5px;"> | |
| <label class="stretch">Route Kicks to Sends</label> | |
| <input type="checkbox" id="route-kicks-to-fx"> | |
| </div> | |
| <div class="row" style="margin-bottom: 15px;"> | |
| <label class="stretch">Route Bass to Sends</label> | |
| <input type="checkbox" id="route-bass-to-fx"> | |
| </div> | |
| <div class="row" style="margin-bottom: 15px;"> | |
| <button id="dub-send-mode-btn">MODE: INTEGRATED</button> | |
| </div> | |
| <div class="row"> | |
| <label>DELAY SEND</label> | |
| <input type="range" id="master-delay-send" min="0" max="1.5" value="0.35" step="0.01"> | |
| </div> | |
| <div class="row"> | |
| <label>DELAY FEEDBACK</label> | |
| <input type="range" id="delay-feedback" min="0" max="0.98" value="0.15" step="0.01"> | |
| </div> | |
| <div class="row"> | |
| <label>REVERB SEND</label> | |
| <input type="range" id="master-reverb-send" min="0" max="1.5" value="0.35" step="0.01"> | |
| </div> | |
| </div> | |
| </div> | |
| <div id="texture-panel" class="control-panel collapsible collapsed"> | |
| <h3>TEXTURE</h3> | |
| <div class="panel-content"> | |
| <div class="row"> | |
| <label>HISS/AIR</label> | |
| <input type="range" id="hiss-density-slider" min="0" max="0.05" value="0" step="0.0005"> | |
| </div> | |
| <div class="row"> | |
| <label>HISS TONE</label> | |
| <input type="range" id="hiss-tone-slider" min="500" max="10000" value="6000" step="100"> | |
| </div> | |
| <div class="row"> | |
| <label>CRACKLE</label> | |
| <input type="range" id="crackle-slider" min="0" max="1" value="0" step="0.01"> | |
| </div> | |
| <div class="row" style="margin-top: 15px;"> | |
| <button id="hiss-patch-btn" class="toggled">PATCHED TO BUS</button> | |
| </div> | |
| </div> | |
| </div> | |
| <div class="control-panel collapsible collapsed" id="dub-system-panel"> | |
| <h3>DUB SYSTEM</h3> | |
| <div class="panel-content"> | |
| <div class="row"> | |
| <label>REDUCTION</label> | |
| <input type="range" id="reduction-slider" min="0" max="1" value="0" step="0.01"> | |
| <span id="reduction-display" class="value-display">0%</span> | |
| </div> | |
| <div class="row"> | |
| <button id="chord-freeze-btn">CHORD STASIS [OFF]</button> | |
| </div> | |
| </div> | |
| </div> | |
| <div class="control-panel collapsible" id="analog-character-panel"> | |
| <h3>ANALOG CHARACTER</h3> | |
| <div class="panel-content"> | |
| <div class="row"> | |
| <label>PROBABILITY</label> | |
| <input type="range" id="global-probability-slider" min="0.1" max="1" value="0.94" step="0.01"> | |
| <span id="global-probability-display" class="value-display">94%</span> | |
| </div> | |
| <div class="row"> | |
| <label for="track-drift-slider">TRACK DRIFT</label> | |
| <input type="range" id="track-drift-slider" min="0" max="0.02" value="0.001" step="0.0005"> | |
| <span id="track-drift-display" class="value-display">1ms</span> | |
| </div> | |
| <div class="row"> | |
| <label>EVOLVE RATE</label> | |
| <input type="range" id="evolve-rate-slider" min="0.01" max="2" value="0.1" step="0.01"> | |
| <span id="evolve-rate-display" class="value-display">0.10Hz</span> | |
| </div> | |
| <div class="row"> | |
| <label>GHOST NOTES</label> | |
| <input type="range" id="ghost-notes-slider" min="0" max="0.25" value="0.06" step="0.005"> | |
| <span id="ghost-notes-display" class="value-display">6%</span> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <div id="visualizer-container"> | |
| <canvas id="phase-clock"></canvas> | |
| </div> | |
| </div> | |
| <div id="track-editor-modal" class="modal-overlay"> | |
| <div class="modal-content"> | |
| <h3 id="modal-title">EDIT TRACK</h3> | |
| <div id="modal-scroll-container"> | |
| <div id="modal-body"></div> | |
| <div id="modal-preset-controls"></div> | |
| </div> | |
| <div id="modal-footer"> | |
| <div class="row"> | |
| <button id="modal-save">SAVE</button> | |
| <button id="modal-close">CANCEL</button> | |
| <button id="modal-delete" style="background: #522; color: #fcc;">DELETE</button> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <div id="genre-preset-modal" class="modal-overlay"> | |
| <div class="modal-content" style="max-width: 350px;"> | |
| <h3 id="genre-modal-title">CHOOSE GENRE PRESET</h3> | |
| <div id="genre-modal-body" style="display: grid; grid-template-columns: 1fr 1fr; gap: 10px;"> | |
| <!-- Genre buttons will be injected here --> | |
| </div> | |
| <div class="row" style="margin-top: 20px;"> | |
| <button id="genre-modal-close" style="width: 100%;">CANCEL</button> | |
| </div> | |
| </div> | |
| </div> | |
| <div id="custom-tooltip" class="hidden"></div> | |
| <script type="module"> | |
| const SCALES = { | |
| minor: [0, 2, 3, 5, 7, 8, 10], | |
| phrygian: [0, 1, 3, 5, 7, 8, 10], | |
| locrian: [0, 1, 3, 5, 6, 8, 10], | |
| chromatic: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11], | |
| }; | |
| const randomInt = (max) => Math.floor(Math.random() * max); | |
| const PRESETS = { | |
| kick: { | |
| default: { synth: 'kick', params: { delaySend: 0, reverbSend: 0.1, fxBypass: true, sequence: [true, false, false, false, true, false, false, false, true, false, false, false, true, false, false, false] } }, | |
| dub: { synth: 'kick', params: { bpm: 90, fxBypass: true, sequence: [false,false,false,false, false,false,false,false, true,false,false,false, false,false,false,false] } }, | |
| dubtechno: { synth: 'kick', params: { bpm: 124, fxBypass: true, sequence: [true, false, false, false, true, false, false, false, true, false, false, false, true, false, false, false] } }, | |
| electro: { synth: 'kick', params: { bpm: 128, fxBypass: true, sequence: [true, false, false, false, false, false, true, false, false, true, false, false, true, false, false, false] } }, | |
| dubstep: { synth: 'kick', params: { bpm: 140, fxBypass: true, sequence: [true, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false] } }, | |
| dubtechno909: { synth: 'kick909', params: { bpm: 122, fxBypass: true, delaySend: 0.1, reverbSend: 0.2, sequence: [true, false, false, false, true, false, false, false, true, false, false, false, true, false, false, false] } }, | |
| }, | |
| snare: { | |
| default: { synth: 'snare', params: { delaySend: 0.4, reverbSend: 0.3, sequence: [false, false, false, false, true, false, false, false, false, false, false, false, true, false, false, false] } }, | |
| dub: { synth: 'snare', params: { bpm: 90, delaySend: 0.8, reverbSend: 0.4, sequence: [false,false,false,false, false,false,false,false, true,false,false,false, false,false,false,false] } }, | |
| dubtechno: { synth: 'snare', params: { bpm: 124, delaySend: 0.6, reverbSend: 0.3, sequence: [false, false, false, false, true, false, false, false, false, false, false, false, true, false, false, false] } }, | |
| electro: { synth: 'snare', params: { bpm: 128, delaySend: 0.3, reverbSend: 0.2, sequence: [false, false, false, false, true, false, false, false, false, false, false, false, true, false, false, true] } }, | |
| dubstep: { synth: 'snare', params: { bpm: 140, delaySend: 0.5, reverbSend: 0.3, sequence: [false, false, false, false, false, false, false, false, true, false, false, false, false, false, false, false] } }, | |
| }, | |
| hats: { | |
| default: { synth: 'hat', mode: 'EUCLID', params: { pulses: 8, offset: 1, volume: 0.8, delaySend: 0.3, reverbSend: 0.2 } }, | |
| dub: { synth: 'hat', mode: 'GATE', params: { bpm: 90, delaySend: 0.8, reverbSend: 0.4, sequence: [false, false, true, false, false, false, true, false, false, false, true, false, false, false, true, false] } }, | |
| dubtechno: { synth: 'hat', mode: 'GATE', params: { bpm: 124, delaySend: 0.6, reverbSend: 0.2, sequence: [false, true, false, true, false, true, false, true, false, true, false, true, false, true, false, true] } }, | |
| electro: { synth: 'hat', mode: 'GATE', params: { bpm: 128, delaySend: 0.2, reverbSend: 0.1, sequence: [false, false, true, false, false, false, true, false, false, false, true, false, false, false, true, false] } }, | |
| dubstep: { synth: 'hat', mode: 'GATE', params: { bpm: 140, delaySend: 0.4, reverbSend: 0.2, sequence: [true, false, true, true, false, false, true, false, true, false, true, true, false, false, true, false] } }, | |
| dubtechno909: { synth: 'hat909', mode: 'GATE', params: { bpm: 122, delaySend: 0.5, reverbSend: 0.1, sequence: [false, false, true, false, false, false, true, false, false, false, true, false, false, false, true, false] } }, | |
| }, | |
| shaker: { | |
| default: { synth: 'shaker', mode: 'EUCLID', params: { steps: 16, pulses: 16, offset: 0, volume: 0.6, delaySend: 0.4, reverbSend: 0.3 } }, | |
| }, | |
| rimshot: { | |
| default: { synth: 'rimshot', mode: 'EUCLID', params: { pulses: 2, delaySend: 0.5, reverbSend: 0.4 } }, | |
| dub: { synth: 'rimshot', mode: 'GATE', params: { bpm: 90, steps: 32, delaySend: 1.2, reverbSend: 0.8, sequence: (() => { const s = Array(32).fill(false); s[12] = true; return s; })() } }, | |
| dubtechno: { synth: 'rimshot', mode: 'EUCLID', params: { bpm: 124, steps: 16, pulses: 3, offset: 11, delaySend: 0.9, reverbSend: 0.5 } }, | |
| electro: { synth: 'rimshot', params: { bpm: 128, delaySend: 0.1, reverbSend: 0.1, sequence: [false, false, false, true, false, false, false, false, false, false, true, false, false, false, false, false] } }, | |
| dubstep: { synth: 'rimshot', mode: 'EUCLID', params: { bpm: 140, steps: 32, pulses: 5, offset: 13, delaySend: 0.5, reverbSend: 0.3 } }, | |
| }, | |
| subbass: { | |
| default: { synth: 'subbass', mode: 'EUCLID', params: { rootNote: 36, steps: 16, pulses: 3, offset: 8, volume: 1.0, delaySend: 0.05, reverbSend: 0.05, fxBypass: true, filterQ: 0.7, highpass: 35, lowpass: 120 } } | |
| }, | |
| midbass: { | |
| default: { synth: 'subbass', mode: 'EUCLID', params: { oscillatorType: 'square', rootNote: 45, steps: 16, pulses: 5, offset: 0, volume: 0.9, delaySend: 0.15, reverbSend: 0.1, fxBypass: true, filterQ: 4.5, highpass: 70, lowpass: 350 } } | |
| }, | |
| dubbass: { | |
| default: { synth: 'subbass', mode: 'EUCLID', params: { oscillatorType: 'square', rootNote: 48, steps: 16, pulses: 4, offset: 12, volume: 0.7, delaySend: 0.4, reverbSend: 0.25, fxBypass: true, modSend: 0.3, filterQ: 2.5, highpass: 180, lowpass: 600 } } | |
| }, | |
| wobblebass: { | |
| default: { synth: 'subbass', mode: 'GATE', params: { oscillatorType: 'square', rootNote: 34, steps: 16, sequence: [true, false, false, false, false, false, false, false, true, false, false, true, false, false, false, false], volume: 1.0, delaySend: 0.2, reverbSend: 0.3, fxBypass: false, attack: 0.01, decay: 0.3, sustain: 0.1, release: 0.1, filterCutoff: 150, filterQ: 8.5, lfoShape: 'sine', lfoRate: 8, lfoDepth: 3500, } } | |
| }, | |
| reese: { | |
| default: { synth: 'reese', mode: 'GATE', params: { rootNote: 28, steps: 32, sequence: (() => { const s = Array(32).fill(false); s[0] = true; return s; })(), volume: 0.8, delaySend: 0.3, reverbSend: 0.4, fxBypass: false } } | |
| }, | |
| drychord: { | |
| default: { synth: 'drychord', mode: 'GATE', params: { rootNote: 60, steps: 16, sequence: [false, false, true, false, false, true, false, false, false, false, true, false, false, true, false, false], volume: 0.7, delaySend: 0.1, reverbSend: 0.1, fxBypass: true } } | |
| }, | |
| dubchord: { default: { synth: 'dubchord', params: { delaySend: 1.0, reverbSend: 0.7, sequence: [false, false, true, false, false, false, true, false, false, false, true, false, false, false, true, false] } } }, | |
| pad: { default: { synth: 'pad', mode: 'EUCLID', params: { rootNote: 38, steps: 64, pulses: 7, offset: 13, volume: 0.7, delaySend: 0.4, reverbSend: 0.9 } } }, | |
| shortpad: { default: { synth: 'shortpad', mode: 'EUCLID', params: { rootNote: 50, steps: 16, pulses: 8, offset: 1, volume: 1.0, delaySend: 0.5, reverbSend: 0.7 } } }, | |
| lead: { default: { synth: 'lead', mode: 'EUCLID', params: { rootNote: 50, scale: 'minor', steps: 16, pulses: 1, offset: 0, volume: 0.8, delaySend: 0.4, reverbSend: 0.6, filterCutoff: 800 } } }, | |
| clap: { default: { synth: 'clap', params: { delaySend: 0.2, reverbSend: 0.3, sequence: [false, false, false, false, true, false, false, false, false, false, false, false, true, false, false, false] } } }, | |
| eskibass: { default: { synth: 'eskibass', mode: 'EUCLID', params: { fxBypass: false, steps: 16, pulses: 5, offset: 0, volume: 1.0, delaySend: 0.1, reverbSend: 0.3 } } }, | |
| siren: { default: { synth: 'siren', mode: 'GATE', params: { steps: 32, delaySend: 1.2, reverbSend: 1.0, sequence: (() => { const s = Array(32).fill(false); s[0] = true; return s; })() } } }, | |
| aggrosiren: { default: { synth: 'siren_aggro', mode: 'GATE', params: { steps: 32, delaySend: 0.8, reverbSend: 0.5, sequence: (() => { const s = Array(32).fill(false); s[0] = true; s[8] = true; return s; })() } } }, | |
| sfx_tuningtone: { | |
| default: { | |
| synth: 'sfx_tuningtone', | |
| mode: 'GATE', | |
| params: { | |
| rootNote: 72, | |
| steps: 16, | |
| delaySend: 1.2, | |
| reverbSend: 1.0, | |
| sequence: (() => { const s = Array(16).fill(false); s[0] = true; return s; })() | |
| } | |
| } | |
| }, | |
| sfx_manualsweep: { default: { synth: 'sfx_manualsweep', mode: 'GATE', params: { steps: 32, delaySend: 0.9, reverbSend: 0.6, sequence: (() => { const s = Array(32).fill(false); s[0] = true; return s; })() } } }, | |
| funkStab: { default: { synth: 'funkstab', mode: 'GATE', params: { rootNote: 60, steps: 16, sequence: [false,false,false,false, false,false,true,false, false,false,false,false, false,true,false,false], volume: 0.8, delaySend: 0.2, reverbSend: 0.15, fxBypass: false } } }, | |
| funkMute: { default: { synth: 'funkmute', mode: 'GATE', params: { steps: 16, sequence: [true,true,true,true, true,true,true,true, true,true,true,true, true,true,true,true], volume: 0.6, delaySend: 0.2, reverbSend: 0.15, fxBypass: false } } }, | |
| noise: { default: { synth: 'noise', mode: 'EUCLID', params: { steps: 16, pulses: 4, offset: 1, volume: 0.7, delaySend: 0.5, reverbSend: 0.7 } } }, | |
| tomfill: { default: { synth: 'rimshot', mode: 'GATE', params: { rootNote: 36, steps: 32, volume: 0.9, delaySend: 0.6, reverbSend: 0.3, sequence: (() => { const s = Array(32).fill(false); s[24]=true; s[26]=true; s[28]=true; return s; })() } } }, | |
| glassystab: { default: { synth: 'sfx_clink', mode: 'EUCLID', params: { rootNote: 72, steps: 16, pulses: 3, offset: 13, volume: 1.0, delaySend: 0.8, reverbSend: 0.6 } } }, | |
| noiseperc: { default: { synth: 'noise', mode: 'EUCLID', params: { steps: 16, pulses: 8, offset: 1, volume: 0.6, delaySend: 0.3, reverbSend: 0.2, fxBypass: false } } }, | |
| sfx_sweep: { default: { synth: 'sfx_sweep', params: { steps: 32, delaySend: 0.5, reverbSend: 0.7, sequence: (() => { const s = Array(32).fill(false); s[0] = true; return s; })() } } }, | |
| sfx_riser: { default: { synth: 'sfx_riser', params: { steps: 64, delaySend: 0.3, reverbSend: 0.5, sequence: (() => { const s = Array(64).fill(false); s[0] = true; return s; })() } } }, | |
| sfx_zap: { default: { synth: 'sfx_zap', mode: 'EUCLID', params: { pulses: 2, offset: 7, delaySend: 0.8, reverbSend: 0.4 } } }, | |
| melodica: { default: { synth: 'melodica', mode: 'EUCLID', params: { rootNote: 72, pulses: 5, offset: 4, delaySend: 0.7, reverbSend: 0.6, scale: 'phrygian' } } }, | |
| synthflute: { default: { synth: 'synthflute', mode: 'EUCLID', params: { rootNote: 84, pulses: 3, offset: 9, delaySend: 0.6, reverbSend: 0.8, scale: 'minor' } } }, | |
| dubguitarriff: { default: { synth: 'dubguitarriff', mode: 'GATE', params: { rootNote: 60, steps: 32, sequence: (() => { const s = Array(32).fill(false); s[6] = true; s[22]=true; return s; })(), volume: 0.7, delaySend: 0.6, reverbSend: 0.6 } } }, | |
| synthfunklick: { default: { synth: 'synthfunklick', mode: 'GATE', params: { rootNote: 48, steps: 16, sequence: [false,false,true,false, false,false,false,true, false,false,true,false, false,false,true,false], volume: 0.9, delaySend: 0.4, reverbSend: 0.3 } } }, | |
| cowbell: { default: { synth: 'cowbell', params: { rootNote: 76, steps: 16, sequence: [false,false,true,false, false,false,true,false, false,false,true,false, false,false,true,false], volume: 0.7, delaySend: 0.3, reverbSend: 0.2 }}}, | |
| clave: { default: { synth: 'clave', params: { rootNote: 88, steps: 16, sequence: [true,false,false,true, false,false,true,false, false,true,false,true, false,false,false,false], volume: 0.8, delaySend: 0.4, reverbSend: 0.3 }}}, | |
| vibraphone: { default: { synth: 'vibraphone', mode: 'EUCLID', params: { rootNote: 72, steps: 32, pulses: 5, offset: 4, volume: 0.6, delaySend: 0.8, reverbSend: 0.7 }}} | |
| }; | |
| class AudioEngine { | |
| constructor(uiManager) { | |
| this.uiManager = uiManager; | |
| this.audioCtx = null; | |
| this.isPlaying = false; | |
| this.bpm = 120; | |
| this.swing = 0.1; | |
| this.scheduleAheadTime = 0.1; | |
| this.nextNoteTime = 0.0; | |
| this.masterStep = 0; | |
| this.sidechainAmount = 0.5; | |
| this.tracks = []; | |
| this.globalProbability = 1.0; | |
| this.trackDrift = 0.001; | |
| this.evolveRate = 0.1; | |
| this.ghostNoteProbability = 0.0; | |
| this.crackleAmount = 0.0; | |
| this.chordStasisMode = 'OFF'; | |
| this.memorizedChordIntervals = null; | |
| this.frozenChordNodes = []; | |
| this.dubSendMode = 'integrated'; // 'integrated' or 'parallel' | |
| this.routeKicksToFx = false; | |
| this.routeBassToFx = false; | |
| this.panelWalkSchedules = {}; | |
| this.panelRndSchedules = {}; | |
| } | |
| async init() { | |
| this.audioCtx = new (window.AudioContext || window.webkitAudioContext)(); | |
| this.masterGain = this.audioCtx.createGain(); this.masterGain.gain.value = 0.7; | |
| this.sidechainBus = this.audioCtx.createGain(); | |
| this.kickBus = this.audioCtx.createGain(); | |
| this.preFilterBus = this.audioCtx.createGain(); | |
| this.modFxBus = this.audioCtx.createGain(); | |
| this.masterDelaySend = this.audioCtx.createGain(); | |
| this.masterReverbSend = this.audioCtx.createGain(); | |
| this.delayBus = this.audioCtx.createGain(); | |
| this.reverbBus = this.audioCtx.createGain(); | |
| this.masterDelaySend.connect(this.delayBus); | |
| this.masterReverbSend.connect(this.reverbBus); | |
| this.preSaturationFilter = this.audioCtx.createBiquadFilter(); this.preSaturationFilter.type = 'peaking'; this.preSaturationFilter.frequency.value = 4000; this.preSaturationFilter.Q.value = 2.0; this.preSaturationFilter.gain.value = -18; | |
| this.saturator = this.audioCtx.createWaveShaper(); this.saturator.curve = null; | |
| this.wowFlutterLFO = this.audioCtx.createOscillator(); this.wowFlutterLFO.frequency.value = 0.5; | |
| this.wowFlutterGain = this.audioCtx.createGain(); this.wowFlutterGain.gain.value = 0; | |
| this.wowFlutterDelay = this.audioCtx.createDelay(0.01); this.wowFlutterDelay.delayTime.value = 0.003; | |
| this.wowFlutterLFO.connect(this.wowFlutterGain); this.wowFlutterGain.connect(this.wowFlutterDelay.delayTime); this.wowFlutterLFO.start(); | |
| this.modFxIn = this.audioCtx.createGain(); | |
| this.modFxWet = this.audioCtx.createGain(); this.modFxWet.gain.value = 0; | |
| this.modFxDry = this.audioCtx.createGain(); this.modFxDry.gain.value = 1; | |
| this.modFxType = 'phaser'; | |
| this.phaser = this.createPhaser(6, 0.5, 500, 3500); | |
| this.flangerDelay = this.audioCtx.createDelay(0.1); this.flangerDelay.delayTime.value = 0.005; | |
| this.flangerFeedback = this.audioCtx.createGain(); this.flangerFeedback.gain.value = 0.6; | |
| this.flangerLFO = this.audioCtx.createOscillator(); this.flangerLFO.frequency.value = 0.2; | |
| this.flangerLfoGain = this.audioCtx.createGain(); this.flangerLfoGain.gain.value = 0.003; | |
| this.flangerLFO.connect(this.flangerLfoGain); this.flangerLfoGain.connect(this.flangerDelay.delayTime); this.flangerLFO.start(); | |
| this.modFxSelect = this.audioCtx.createGain(); this.modFxSelect.gain.value = 1; | |
| this.delay1FeedbackSaturator = this.audioCtx.createWaveShaper(); | |
| this.delay2FeedbackSaturator = this.audioCtx.createWaveShaper(); | |
| this.delay = this.audioCtx.createDelay(5.0); this.delayFeedback = this.audioCtx.createGain(); this.delayWet = this.audioCtx.createGain(); | |
| this.delayLpf = this.audioCtx.createBiquadFilter(); this.delayLpf.type = 'lowpass'; this.delayLpf.frequency.value = 16000; | |
| this.delayHpf = this.audioCtx.createBiquadFilter(); this.delayHpf.type = 'highpass'; this.delayHpf.frequency.value = 20; | |
| this.delay2 = this.audioCtx.createDelay(5.0); this.delay2Feedback = this.audioCtx.createGain(); this.delay2Wet = this.audioCtx.createGain(); | |
| this.tapeAgeFilter1 = this.audioCtx.createBiquadFilter(); this.tapeAgeFilter1.type = 'lowpass'; this.tapeAgeFilter1.frequency.value = 22000; | |
| this.tapeAgeFilter2 = this.audioCtx.createBiquadFilter(); this.tapeAgeFilter2.type = 'lowpass'; this.tapeAgeFilter2.frequency.value = 22000; | |
| this.reverbPreDelay = this.audioCtx.createDelay(0.5); this.reverbPreDelay.delayTime.value = 0.02; | |
| this.reverb = await this.createReverb(); this.reverbWet = this.audioCtx.createGain(); | |
| this.delayPitchShifter = this.audioCtx.createBiquadFilter(); this.delayPitchShifter.type = 'allpass'; | |
| this.delayPitchLFO = this.audioCtx.createOscillator(); this.delayPitchLFO.type = 'sine'; this.delayPitchLFO.frequency.value = 2; | |
| this.delayPitchLfoGain = this.audioCtx.createGain(); this.delayPitchLfoGain.gain.value = 0; | |
| this.delayPitchLFO.connect(this.delayPitchLfoGain); this.delayPitchLfoGain.connect(this.delayPitchShifter.detune); | |
| this.delayPitchLFO.start(); | |
| this.hissSource = this.audioCtx.createBufferSource(); this.hissFilter = this.audioCtx.createBiquadFilter(); | |
| this.hissGain = this.audioCtx.createGain(); | |
| this.hissPlayGate = this.audioCtx.createGain(); | |
| this.hissModulator = this.audioCtx.createBufferSource(); | |
| this.hissModulatorGain = this.audioCtx.createGain(); | |
| this.setupHissGenerator(); | |
| this.chordFilterLFO = this.audioCtx.createOscillator(); this.chordDetuneLFO = this.audioCtx.createOscillator(); this.setupChordEvolveLFOs(); | |
| this.analogGlueSaturator = this.audioCtx.createWaveShaper(); this.analogGlueSaturator.curve = this.makeDistortionCurve(0.05); | |
| this.filter = this.audioCtx.createBiquadFilter(); this.filter.type = "lowpass"; this.filter.frequency.value = 8000; this.filter.Q.value = 1; | |
| this.compressor = this.audioCtx.createDynamicsCompressor(); this.compressor.threshold.value = -12; this.compressor.knee.value = 15; this.compressor.ratio.value = 2.5; this.compressor.attack.value = 0.01; this.compressor.release.value = 0.1; | |
| this.masterHPF = this.audioCtx.createBiquadFilter(); | |
| this.masterHPF.type = 'highpass'; | |
| this.masterHPF.frequency.value = 28; | |
| this.masterHPF.Q.value = 0.7; | |
| this.limiter = this.audioCtx.createDynamicsCompressor(); this.limiter.threshold.value = -0.5; this.limiter.knee.value = 0; this.limiter.ratio.value = 20; this.limiter.attack.value = 0.001; this.limiter.release.value = 0.05; | |
| this.hissCrossfade = this.audioCtx.createGain(); | |
| this.hissToFx = this.audioCtx.createGain(); | |
| this.hissToMaster = this.audioCtx.createGain(); | |
| this.hissToFx.gain.value = 1.0; | |
| this.hissToMaster.gain.value = 0.0; | |
| this.hissCrossfade.connect(this.hissToFx).connect(this.sidechainBus); | |
| this.hissCrossfade.connect(this.hissToMaster).connect(this.masterHPF); | |
| // Audio Routing | |
| this.kickBus.connect(this.masterGain); | |
| this.sidechainBus.connect(this.masterGain); | |
| this.modFxBus.connect(this.modFxIn); | |
| this.delayBus.connect(this.saturator); | |
| this.reverbBus.connect(this.reverbPreDelay); | |
| this.modFxIn.connect(this.phaser); this.phaser.connect(this.modFxSelect); | |
| this.modFxIn.connect(this.flangerDelay); this.flangerDelay.connect(this.flangerFeedback); this.flangerFeedback.connect(this.flangerDelay); | |
| this.flangerDelay.connect(this.modFxWet); | |
| this.modFxSelect.connect(this.modFxWet); | |
| this.modFxIn.connect(this.modFxDry); | |
| this.modFxWet.connect(this.preSaturationFilter); | |
| this.modFxDry.connect(this.preSaturationFilter); | |
| this.preSaturationFilter.connect(this.saturator); | |
| this.saturator.connect(this.delay); | |
| this.delay.connect(this.delayFeedback); | |
| this.delayFeedback.connect(this.delay1FeedbackSaturator); | |
| this.delay1FeedbackSaturator.connect(this.delayLpf); | |
| this.delayLpf.connect(this.delayHpf); | |
| this.delayHpf.connect(this.delayPitchShifter); | |
| this.delayPitchShifter.connect(this.tapeAgeFilter1); | |
| this.tapeAgeFilter1.connect(this.delay); | |
| this.delay.connect(this.delayWet); | |
| this.saturator.connect(this.delay2); this.delay2.connect(this.delay2Feedback); this.delay2Feedback.connect(this.delay2FeedbackSaturator); this.delay2FeedbackSaturator.connect(this.tapeAgeFilter2); this.tapeAgeFilter2.connect(this.delay2); this.delay2.connect(this.delay2Wet); | |
| this.reverbPreDelay.connect(this.reverb); this.reverb.connect(this.reverbWet); | |
| this.masterGain.connect(this.preFilterBus); | |
| this.delayWet.connect(this.preFilterBus); | |
| this.delay2Wet.connect(this.preFilterBus); | |
| this.reverbWet.connect(this.preFilterBus); | |
| this.preFilterBus.connect(this.analogGlueSaturator); | |
| this.analogGlueSaturator.connect(this.filter); | |
| this.filter.connect(this.compressor); | |
| this.compressor.connect(this.wowFlutterDelay); | |
| this.wowFlutterDelay.connect(this.masterHPF); | |
| this.compressor.connect(this.masterHPF); | |
| this.hissOutput = this.audioCtx.createGain(); | |
| this.hissGain.connect(this.hissPlayGate); | |
| this.hissPlayGate.connect(this.hissOutput); | |
| this.hissOutput.connect(this.hissCrossfade); | |
| this.masterHPF.connect(this.limiter); | |
| this.limiter.connect(this.audioCtx.destination); | |
| this.setDelayAmount(0.4); | |
| this.setDelay2Amount(0.2); | |
| this.setReverbAmount(0.25); | |
| this.setDelayFeedback(0.15); | |
| } | |
| setPanelAutomation(panelId, type, interval) { | |
| const val = parseInt(interval, 10); | |
| const targetSchedule = type === 'walk' ? this.panelWalkSchedules : this.panelRndSchedules; | |
| if (val > 0) { | |
| targetSchedule[panelId] = val; | |
| } else { | |
| delete targetSchedule[panelId]; | |
| } | |
| } | |
| patchHissToFx(isPatched) { | |
| if (!this.hissToFx || !this.audioCtx) return; | |
| const now = this.audioCtx.currentTime; | |
| const transitionTime = 0.05; | |
| if (isPatched) { | |
| this.hissToMaster.gain.setTargetAtTime(0.0, now, transitionTime); | |
| this.hissToFx.gain.setTargetAtTime(1.0, now, transitionTime); | |
| } else { | |
| this.hissToMaster.gain.setTargetAtTime(1.0, now, transitionTime); | |
| this.hissToFx.gain.setTargetAtTime(0.0, now, transitionTime); | |
| } | |
| } | |
| createNoiseLFOBuffer() { | |
| const bufferSize = this.audioCtx.sampleRate * 4; | |
| const buffer = this.audioCtx.createBuffer(1, bufferSize, this.audioCtx.sampleRate); | |
| const data = buffer.getChannelData(0); | |
| let lastValue = 0; | |
| for (let i = 0; i < bufferSize; i++) { | |
| const white = Math.random() * 2 - 1; | |
| data[i] = (lastValue + (0.02 * white)) / 1.01; | |
| lastValue = data[i]; | |
| } | |
| return buffer; | |
| } | |
| setupHissGenerator() { | |
| const bufferSize = this.audioCtx.sampleRate * 2; | |
| const buffer = this.audioCtx.createBuffer(1, bufferSize, this.audioCtx.sampleRate); | |
| const data = buffer.getChannelData(0); | |
| for (let i = 0; i < bufferSize; i++) data[i] = Math.random() * 2 - 1; | |
| this.hissSource.buffer = buffer; this.hissSource.loop = true; | |
| this.hissFilter.type = 'bandpass'; this.hissFilter.frequency.value = 6000; this.hissFilter.Q.value = 1.5; | |
| this.hissGain.gain.value = 0.0; | |
| this.hissModulator.buffer = this.createNoiseLFOBuffer(); | |
| this.hissModulator.loop = true; | |
| this.hissModulatorGain.gain.value = 0.5; | |
| this.hissModulator.connect(this.hissModulatorGain); | |
| this.hissPlayGate.gain.value = 0.0; | |
| this.hissSource.connect(this.hissFilter); | |
| this.hissFilter.connect(this.hissGain); | |
| this.hissModulatorGain.connect(this.hissGain.gain); | |
| this.hissSource.start(); | |
| this.hissModulator.start(); | |
| } | |
| setupChordEvolveLFOs() { this.chordFilterLFO.type = 'sine'; this.chordFilterLFO.frequency.value = this.evolveRate; this.chordFilterLFO.start(); this.chordDetuneLFO.type = 'sine'; this.chordDetuneLFO.frequency.value = this.evolveRate * 1.3; this.chordDetuneLFO.start(); } | |
| makeDistortionCurve(amount) { const k = (typeof amount === 'number' ? amount * 100 : 50) * (amount * 0.8); const n_samples = 44100; const curve = new Float32Array(n_samples); const deg = Math.PI / 180; for (let i = 0; i < n_samples; ++i) { const x = i * 2 / n_samples - 1; curve[i] = (3 + k) * x * 20 * deg / (Math.PI + k * Math.abs(x)); } return curve; } | |
| async createReverb() { const convolver = this.audioCtx.createConvolver(); const impulseLen = this.audioCtx.sampleRate * 2.5; const impulse = this.audioCtx.createBuffer(2, impulseLen, this.audioCtx.sampleRate); for (let c = 0; c < 2; c++) { const data = impulse.getChannelData(c); for (let i = 0; i < impulseLen; i++) { data[i] = (Math.random() * 2 - 1) * Math.pow(1 - i / impulseLen, 3.5); } } convolver.buffer = impulse; return convolver; } | |
| createPhaser(stages, rate, baseFreq, range) { const context = this.audioCtx; const input = context.createGain(); const lfo = context.createOscillator(); lfo.frequency.value = rate; const lfoGain = context.createGain(); lfoGain.gain.value = range; lfo.connect(lfoGain); lfo.start(); let filters = []; for (let i = 0; i < stages; i++) { const filter = context.createBiquadFilter(); filter.type = 'allpass'; filter.frequency.value = baseFreq; filter.Q.value = 5; lfoGain.connect(filter.frequency); input.connect(filter); if(i > 0) filters[i-1].connect(filter); filters.push(filter); } input.connect(filters[stages-1]); return input; } | |
| async togglePlay() { | |
| if (!this.audioCtx) await this.init(); | |
| if (this.audioCtx.state === 'suspended') this.audioCtx.resume(); | |
| this.isPlaying = !this.isPlaying; | |
| const now = this.audioCtx.currentTime; | |
| if (this.isPlaying) { | |
| this.nextNoteTime = now + 0.1; | |
| this.masterStep = -1; | |
| this.scheduler(); | |
| this.hissPlayGate.gain.setTargetAtTime(1.0, now, 0.1); | |
| this.scheduleCrackle(); | |
| } else { | |
| this.hissPlayGate.gain.setTargetAtTime(0.0, now, 0.5); | |
| } | |
| return this.isPlaying; | |
| } | |
| playCrackle(time, volume) { | |
| const gain = this.audioCtx.createGain(); | |
| gain.gain.setValueAtTime(volume * 5, time); | |
| gain.gain.exponentialRampToValueAtTime(0.001, time + 0.03); | |
| const bandpass = this.audioCtx.createBiquadFilter(); | |
| bandpass.type = 'bandpass'; | |
| bandpass.frequency.value = 3000 + Math.random() * 4000; | |
| bandpass.Q.value = 15; | |
| const noise = this.audioCtx.createBufferSource(); | |
| const buffer = this.audioCtx.createBuffer(1, this.audioCtx.sampleRate * 0.02, this.audioCtx.sampleRate); | |
| const data = buffer.getChannelData(0); for (let i = 0; i < data.length; i++) data[i] = Math.random() * 2 - 1; | |
| noise.buffer = buffer; | |
| noise.connect(bandpass); | |
| bandpass.connect(gain); | |
| gain.connect(this.masterHPF); | |
| noise.start(time); | |
| noise.stop(time + 0.02); | |
| } | |
| scheduleCrackle() { | |
| if (!this.isPlaying) return; | |
| if (Math.random() < this.crackleAmount) { | |
| const crackleTime = this.audioCtx.currentTime + Math.random() * 0.5; | |
| const crackleVolume = (0.05 + Math.random() * 0.15) * this.crackleAmount; | |
| this.playCrackle(crackleTime, crackleVolume); | |
| } | |
| const nextCheck = 200 + Math.random() * 800; | |
| setTimeout(() => this.scheduleCrackle(), nextCheck); | |
| } | |
| scheduler() { if (!this.isPlaying) return; while (this.nextNoteTime < this.audioCtx.currentTime + this.scheduleAheadTime) { const secondsPerStep = 15.0 / this.bpm; this.masterStep++; this.scheduleStep(this.masterStep, this.nextNoteTime); this.nextNoteTime += secondsPerStep; } setTimeout(() => this.scheduler(), 25); } | |
| scheduleStep(step, time) { | |
| Object.keys(this.panelWalkSchedules).forEach(panelId => { | |
| const interval = this.panelWalkSchedules[panelId]; | |
| if (step > 0 && step % interval === 0) { | |
| this.uiManager.walkPanelById(panelId); | |
| } | |
| }); | |
| Object.keys(this.panelRndSchedules).forEach(panelId => { | |
| const interval = this.panelRndSchedules[panelId]; | |
| if (step > 0 && step % interval === 0) { | |
| this.uiManager.randomizePanelById(panelId); | |
| } | |
| }); | |
| const anySolo = this.tracks.some(t => t.solo); | |
| const kickIsTriggering = this.tracks.some(track => track.active && !track.mute && (!anySolo || track.solo) && (track.synth === 'kick' || track.synth === 'kick909') && track.shouldTrigger(step % track.params.steps)); | |
| if (kickIsTriggering) { | |
| this.sidechainBus.gain.cancelScheduledValues(time); this.sidechainBus.gain.setValueAtTime(1.0, time); | |
| const duckAmount = 1.0 - this.sidechainAmount; this.sidechainBus.gain.linearRampToValueAtTime(duckAmount, time + 0.01); this.sidechainBus.gain.linearRampToValueAtTime(1.0, time + 0.15); | |
| } | |
| this.tracks.forEach(track => { | |
| if (!track.active || track.mute || (anySolo && !track.solo)) return; | |
| const stepInLoop = step % track.params.steps; | |
| const shouldPlay = track.shouldTrigger(stepInLoop); | |
| let swingDelay = 0; | |
| const secondsPerStep = 15.0 / this.bpm; | |
| const swingAmountToUse = (track.params.useGlobalSwing === false) ? track.params.swingAmount : this.swing; | |
| if ((step) % 2 !== 0) { | |
| swingDelay = secondsPerStep * swingAmountToUse; | |
| } | |
| const drift = (Math.random() * 2 - 1) * this.trackDrift; | |
| const finalTime = time + drift + swingDelay; | |
| if (shouldPlay) { | |
| this.uiManager.triggerPulse(track, track.params.fxBypass); | |
| const stepParams = track.params.stepParams[stepInLoop] || {}; | |
| const finalProbability = (stepParams.probability ?? 1.0) * this.globalProbability; | |
| if (Math.random() <= finalProbability) { | |
| const ratchets = stepParams.ratchets ?? 1; | |
| const ratchetDuration = (secondsPerStep / ratchets) * 0.95; | |
| for (let i = 0; i < ratchets; i++) { | |
| const ratchetTime = finalTime + (i * ratchetDuration); | |
| this.playSynth(track, ratchetTime, stepInLoop); | |
| } | |
| track.ui.setTriggered(step); | |
| track.ui.element.classList.add('triggered-track'); | |
| setTimeout(() => track.ui.element.classList.remove('triggered-track'), 100); | |
| } | |
| } | |
| if (track.params.allowGhostNotes && Math.random() < this.ghostNoteProbability) { | |
| const velocity = track.params.volume * (0.1 + Math.random() * 0.2); | |
| this.playSynth(track, finalTime, stepInLoop, velocity); | |
| } | |
| }); | |
| this.uiManager.updateCurrentStep(step); | |
| } | |
| createTrackFilterChain(params, destination) { | |
| const lowpass = this.audioCtx.createBiquadFilter(); | |
| lowpass.type = 'lowpass'; | |
| lowpass.frequency.value = params.lowpass || 20000; | |
| lowpass.Q.value = 0.7; | |
| const highpass = this.audioCtx.createBiquadFilter(); | |
| highpass.type = 'highpass'; | |
| highpass.frequency.value = params.highpass || 20; | |
| highpass.Q.value = 0.7; | |
| highpass.connect(lowpass); | |
| lowpass.connect(destination); | |
| return { input: highpass, output: lowpass }; | |
| } | |
| playSynth(track, time, step, overrideVelocity) { | |
| const stepParams = track.params.stepParams[step] || {}; | |
| let velocity = overrideVelocity ?? stepParams.velocity ?? track.params.volume; | |
| const isAccented = stepParams.accent ?? false; | |
| if (isAccented) { | |
| velocity = Math.min(velocity * 1.4, 1.5); | |
| } | |
| const octaveShift = stepParams.octave ?? 0; | |
| const gate = stepParams.gate ?? 1.0; | |
| const gainNode = this.audioCtx.createGain(); | |
| gainNode.gain.value = velocity; | |
| const isKick = ['kick', 'kick909'].includes(track.synth); | |
| const isBass = ['subbass', 'midbass', 'dubbass', 'eskibass', 'reese'].includes(track.synth); | |
| const shouldRouteKick = isKick && this.routeKicksToFx; | |
| const shouldRouteBass = isBass && this.routeBassToFx; | |
| const isRoutedLowEnd = shouldRouteKick || shouldRouteBass; | |
| let useFxPathForMainSignal = !track.params.fxBypass; | |
| if (isRoutedLowEnd && this.dubSendMode === 'integrated') { | |
| useFxPathForMainSignal = true; | |
| } | |
| if (useFxPathForMainSignal) { | |
| const destinationBus = (isKick || isBass) ? this.kickBus : this.sidechainBus; | |
| const filterChain = this.createTrackFilterChain(track.params, destinationBus); | |
| gainNode.connect(filterChain.input); | |
| if (track.params.modSend > 0) { const send = this.audioCtx.createGain(); send.gain.value = track.params.modSend; gainNode.connect(send); send.connect(this.modFxBus); } | |
| if (track.params.delaySend > 0) { const send = this.audioCtx.createGain(); send.gain.value = track.params.delaySend; gainNode.connect(send); send.connect(this.masterDelaySend); } | |
| if (track.params.reverbSend > 0) { const send = this.audioCtx.createGain(); send.gain.value = track.params.reverbSend; gainNode.connect(send); send.connect(this.masterReverbSend); } | |
| } else { | |
| const filterChain = this.createTrackFilterChain(track.params, this.compressor); | |
| gainNode.connect(filterChain.input); | |
| } | |
| if (isRoutedLowEnd && this.dubSendMode === 'parallel') { | |
| if (track.params.modSend > 0) { const send = this.audioCtx.createGain(); send.gain.value = track.params.modSend; gainNode.connect(send); send.connect(this.modFxBus); } | |
| if (track.params.delaySend > 0) { const send = this.audioCtx.createGain(); send.gain.value = track.params.delaySend; gainNode.connect(send); send.connect(this.masterDelaySend); } | |
| if (track.params.reverbSend > 0) { const send = this.audioCtx.createGain(); send.gain.value = track.params.reverbSend; gainNode.connect(send); send.connect(this.masterReverbSend); } | |
| } | |
| const pitchMultiplier = Math.pow(2, ((track.params.rootNote - 60) / 12)); | |
| switch(track.synth) { | |
| case 'kick': this.playKick(time, track.params, gainNode, pitchMultiplier, octaveShift, gate); break; | |
| case 'kick909': this.playKick909(time, track.params, gainNode, pitchMultiplier, octaveShift, gate); break; | |
| case 'snare': this.playSnare(time, track.params, gainNode, pitchMultiplier, octaveShift, gate); break; | |
| case 'clap': this.playClap(time, track.params, gainNode, pitchMultiplier, octaveShift, gate); break; | |
| case 'rimshot': this.playRimshot(time, track.params, gainNode, pitchMultiplier, octaveShift, gate); break; | |
| case 'hat': this.playHat(time, track.params, gainNode, pitchMultiplier, octaveShift, gate); break; | |
| case 'hat909': this.playHat909(time, track.params, gainNode, pitchMultiplier, octaveShift, gate); break; | |
| case 'shaker': this.playShaker(time, track.params, gainNode, pitchMultiplier, octaveShift, gate); break; | |
| case 'cowbell': this.playCowbell(time, track.params, gainNode, octaveShift, gate); break; | |
| case 'clave': this.playClave(time, track.params, gainNode, octaveShift, gate); break; | |
| case 'stab': this.playStab(time, track.params, gainNode, octaveShift, gate); break; | |
| case 'funkstab': this.playFunkStab(time, track.params, gainNode, octaveShift, gate); break; | |
| case 'funkmute': this.playFunkMute(time, track.params, gainNode, octaveShift, gate); break; | |
| case 'dubguitarriff': this.playDubGuitarLick(time, track.params, gainNode, octaveShift, gate); break; | |
| case 'synthfunklick': this.playSynthFunkLick(time, track.params, gainNode, octaveShift, gate); break; | |
| case 'dubchord': this.playDubChord(time, track.params, gainNode, octaveShift, gate); break; | |
| case 'drychord': this.playDryChord(time, track.params, gainNode, octaveShift, gate); break; | |
| case 'subbass': this.playSubBass(time, track.params, gainNode, octaveShift, gate); break; | |
| case 'reese': this.playReeseBass(time, track.params, gainNode, octaveShift, gate); break; | |
| case 'eskibass': this.playEskiBass(time, track.params, gainNode, octaveShift, gate); break; | |
| case 'lead': this.playLead(time, track.params, gainNode, octaveShift, gate); break; | |
| case 'melodica': this.playMelodica(time, track.params, gainNode, octaveShift, gate); break; | |
| case 'synthflute': this.playSynthFlute(time, track.params, gainNode, octaveShift, gate); break; | |
| case 'pad': this.playDeepPad(time, track.params, gainNode, octaveShift, gate); break; | |
| case 'shortpad': this.playShortPad(time, track.params, gainNode, octaveShift, gate); break; | |
| case 'vibraphone': this.playVibraphone(time, track.params, gainNode, octaveShift, gate); break; | |
| case 'noise': this.playNoise(time, track.params, gainNode, pitchMultiplier, octaveShift, gate); break; | |
| case 'siren': this.playSiren(time, track.params, gainNode, octaveShift, gate); break; | |
| case 'siren_aggro': this.playAggroSiren(time, track.params, gainNode, octaveShift, gate); break; | |
| case 'sfx_tuningtone': this.playTuningTone(time, track.params, gainNode, octaveShift, gate); break; | |
| case 'sfx_manualsweep': this.playManualSweep(time, track.params, gainNode, octaveShift, gate); break; | |
| case 'sfx_sweep': this.playSfxSweep(time, gainNode, pitchMultiplier, octaveShift, gate); break; | |
| case 'sfx_riser': this.playSfxRiser(time, gainNode, pitchMultiplier, octaveShift, gate); break; | |
| case 'sfx_zap': this.playSfxZap(time, track.params, gainNode, pitchMultiplier, octaveShift, gate); break; | |
| case 'sfx_clink': this.playSfxClink(time, track.params, gainNode, pitchMultiplier, octaveShift, gate); break; | |
| } | |
| } | |
| playKick(time, p, destination, pitchMultiplier, octaveShift = 0, gate = 1) { const finalPitch = pitchMultiplier * Math.pow(2, octaveShift); const osc = this.audioCtx.createOscillator(); const gain = this.audioCtx.createGain(); const duration = 0.2 * p.decay * gate; osc.type = 'sine'; osc.frequency.setValueAtTime(120 * finalPitch, time); osc.frequency.exponentialRampToValueAtTime(40 * finalPitch, time + duration * 0.75); gain.gain.setValueAtTime(5, time); gain.gain.exponentialRampToValueAtTime(0.01, time + duration); osc.connect(gain); gain.connect(destination); osc.start(time); osc.stop(time + duration); } | |
| playKick909(time, p, destination, pitchMultiplier, octaveShift = 0, gate = 1) { const finalPitch = pitchMultiplier * Math.pow(2, octaveShift); const osc = this.audioCtx.createOscillator(); const gain = this.audioCtx.createGain(); const distortion = this.audioCtx.createWaveShaper(); const duration = 0.15 * p.decay * gate; distortion.curve = this.makeDistortionCurve(0.8); osc.type = 'triangle'; osc.frequency.setValueAtTime(150 * finalPitch, time); osc.frequency.exponentialRampToValueAtTime(50 * finalPitch, time + duration * 0.53); gain.gain.setValueAtTime(1, time); gain.gain.exponentialRampToValueAtTime(0.01, time + duration); osc.connect(gain); gain.connect(distortion); distortion.connect(destination); osc.start(time); osc.stop(time + duration); } | |
| playSnare(time, p, destination, pitchMultiplier, octaveShift = 0, gate = 1) { const finalPitch = pitchMultiplier * Math.pow(2, octaveShift); const gain = this.audioCtx.createGain(); const duration = 0.15 * p.decay * gate; gain.gain.setValueAtTime(2, time); gain.gain.exponentialRampToValueAtTime(0.01, time + duration); gain.connect(destination); const noise = this.audioCtx.createBufferSource(); const buffer = this.audioCtx.createBuffer(1, this.audioCtx.sampleRate * duration, this.audioCtx.sampleRate); const data = buffer.getChannelData(0); for (let i = 0; i < data.length; i++) data[i] = Math.random() * 2 - 1; noise.buffer = buffer; const bandpass = this.audioCtx.createBiquadFilter(); bandpass.type = 'bandpass'; bandpass.frequency.value = p.tone * finalPitch; bandpass.Q.value = 2; noise.connect(bandpass); bandpass.connect(gain); noise.start(time); noise.stop(time + duration); } | |
| playClap(time, p, destination, pitchMultiplier, octaveShift = 0, gate = 1) { const finalPitch = pitchMultiplier * Math.pow(2, octaveShift); const gain = this.audioCtx.createGain(); const duration = 0.15 * p.decay * gate; gain.gain.setValueAtTime(1.5, time); gain.gain.exponentialRampToValueAtTime(0.01, time + duration); gain.connect(destination); const playBurst = (offset) => { const noise = this.audioCtx.createBufferSource(); const buffer = this.audioCtx.createBuffer(1, this.audioCtx.sampleRate * 0.05, this.audioCtx.sampleRate); const data = buffer.getChannelData(0); for (let i = 0; i < data.length; i++) data[i] = Math.random() * 2 - 1; noise.buffer = buffer; const bandpass = this.audioCtx.createBiquadFilter(); bandpass.type = 'bandpass'; bandpass.frequency.value = p.tone * finalPitch; bandpass.Q.value = 3; noise.connect(bandpass); bandpass.connect(gain); noise.start(time + offset); noise.stop(time + offset + 0.05); }; playBurst(0); playBurst(0.008); playBurst(0.016); } | |
| playRimshot(time, p, destination, pitchMultiplier, octaveShift = 0, gate = 1) { const finalPitch = pitchMultiplier * Math.pow(2, octaveShift); const gain = this.audioCtx.createGain(); const duration = 0.15 * p.decay * gate; gain.gain.setValueAtTime(1.2, time); gain.gain.exponentialRampToValueAtTime(0.01, time + duration); gain.connect(destination); const osc = this.audioCtx.createOscillator(); osc.type = 'sine'; osc.frequency.setValueAtTime(1200 * finalPitch, time); osc.frequency.exponentialRampToValueAtTime(200 * finalPitch, time + duration * 0.9); osc.connect(gain); osc.start(time); osc.stop(time + duration); const noise = this.audioCtx.createBufferSource(); const buffer = this.audioCtx.createBuffer(1, this.audioCtx.sampleRate * 0.02, this.audioCtx.sampleRate); const data = buffer.getChannelData(0); for (let i = 0; i < data.length; i++) data[i] = Math.random() * 2 - 1; noise.buffer = buffer; const bandpass = this.audioCtx.createBiquadFilter(); bandpass.type = 'bandpass'; bandpass.frequency.value = p.tone * finalPitch; bandpass.Q.value = 4; noise.connect(bandpass); bandpass.connect(gain); noise.start(time); noise.stop(time + 0.02); } | |
| playHat(time, p, destination, pitchMultiplier, octaveShift = 0, gate = 1) { const finalPitch = pitchMultiplier * Math.pow(2, octaveShift); const gain = this.audioCtx.createGain(); const duration = 0.04 * p.decay * gate; const hipass = this.audioCtx.createBiquadFilter(); hipass.type = "highpass"; hipass.frequency.value = 8000 * finalPitch; const noise = this.audioCtx.createBufferSource(); const buffer = this.audioCtx.createBuffer(1, this.audioCtx.sampleRate * duration, this.audioCtx.sampleRate); const data = buffer.getChannelData(0); for (let i = 0; i < data.length; i++) data[i] = Math.random() * 2 - 1; noise.buffer = buffer; noise.connect(hipass); hipass.connect(gain); gain.connect(destination); gain.gain.setValueAtTime(0.1, time); gain.gain.exponentialRampToValueAtTime(0.001, time + duration); noise.start(time); noise.stop(time + duration); } | |
| playShaker(time, p, destination, pitchMultiplier, octaveShift = 0, gate = 1) { const gain = this.audioCtx.createGain(); const duration = 0.1 * p.decay * gate; const bandpass = this.audioCtx.createBiquadFilter(); bandpass.type = "bandpass"; bandpass.frequency.value = 7000; bandpass.Q.value = 1; const noise = this.audioCtx.createBufferSource(); const buffer = this.audioCtx.createBuffer(1, this.audioCtx.sampleRate * duration, this.audioCtx.sampleRate); const data = buffer.getChannelData(0); for (let i = 0; i < data.length; i++) data[i] = Math.random() * 2 - 1; noise.buffer = buffer; noise.connect(bandpass); bandpass.connect(gain); gain.connect(destination); gain.gain.setValueAtTime(0, time); gain.gain.linearRampToValueAtTime(0.3, time + 0.01); gain.gain.exponentialRampToValueAtTime(0.001, time + duration); noise.start(time); noise.stop(time + duration); } | |
| playHat909(time, p, destination, pitchMultiplier, octaveShift = 0, gate = 1) { const finalPitch = pitchMultiplier * Math.pow(2, octaveShift); const fundamental = 40 * finalPitch; const ratios = [2, 3, 4.16, 5.43, 6.79, 8.21]; const duration = 0.05 * p.decay * gate; const bandpass = this.audioCtx.createBiquadFilter(); bandpass.type = 'bandpass'; bandpass.frequency.value = 10000; bandpass.Q.value = 1.5; const highpass = this.audioCtx.createBiquadFilter(); highpass.type = 'highpass'; highpass.frequency.value = 7000; const gain = this.audioCtx.createGain(); gain.gain.setValueAtTime(1, time); gain.gain.exponentialRampToValueAtTime(0.01, time + duration); ratios.forEach(ratio => { const osc = this.audioCtx.createOscillator(); osc.type = 'square'; osc.frequency.value = fundamental * ratio; osc.connect(bandpass); osc.start(time); osc.stop(time + duration); }); bandpass.connect(highpass); highpass.connect(gain); gain.connect(destination); } | |
| playFunkStab(time, p, destination, octaveShift = 0, gate = 1) { | |
| const root = p.rootNote + (octaveShift * 12); | |
| const chordIntervals = [0, 3, 7, 10]; | |
| const stopTime = time + 0.2 * gate; | |
| const strumDelay = 0.005; | |
| const amp = this.audioCtx.createGain(); | |
| const filter = this.audioCtx.createBiquadFilter(); | |
| const compressor = this.audioCtx.createDynamicsCompressor(); | |
| filter.type = 'lowpass'; | |
| filter.Q.value = 8; | |
| filter.frequency.setValueAtTime(4000, time); | |
| filter.frequency.exponentialRampToValueAtTime(500, time + 0.1 * gate); | |
| amp.gain.setValueAtTime(0, time); | |
| amp.gain.linearRampToValueAtTime(0.3, time + 0.01); | |
| amp.gain.exponentialRampToValueAtTime(0.001, stopTime); | |
| compressor.threshold.value = -18; | |
| compressor.knee.value = 12; | |
| compressor.ratio.value = 6; | |
| compressor.attack.value = 0.005; | |
| compressor.release.value = 0.1; | |
| chordIntervals.forEach((interval, index) => { | |
| const osc = this.audioCtx.createOscillator(); | |
| osc.type = 'sawtooth'; | |
| const freq = 440 * Math.pow(2, (root + interval - 69) / 12); | |
| osc.frequency.value = freq; | |
| osc.detune.value = (Math.random() - 0.5) * 5; | |
| osc.connect(filter); | |
| const startTime = time + (index * strumDelay); | |
| osc.start(startTime); | |
| osc.stop(stopTime); | |
| }); | |
| filter.connect(compressor); | |
| compressor.connect(amp); | |
| amp.connect(destination); | |
| } | |
| playFunkMute(time, p, destination, octaveShift = 0, gate = 1) { | |
| const duration = 0.05 * gate; | |
| const amp = this.audioCtx.createGain(); | |
| const filter = this.audioCtx.createBiquadFilter(); | |
| const noise = this.audioCtx.createBufferSource(); | |
| const bufferSize = this.audioCtx.sampleRate * duration; | |
| const buffer = this.audioCtx.createBuffer(1, bufferSize, this.audioCtx.sampleRate); | |
| const data = buffer.getChannelData(0); | |
| for (let i = 0; i < bufferSize; i++) { data[i] = Math.random() * 2 - 1; } | |
| noise.buffer = buffer; | |
| filter.type = 'bandpass'; | |
| filter.frequency.value = 3000; | |
| filter.Q.value = 3.5; | |
| amp.gain.setValueAtTime(1.0, time); | |
| amp.gain.exponentialRampToValueAtTime(0.001, time + duration); | |
| noise.connect(filter); | |
| filter.connect(amp); | |
| amp.connect(destination); | |
| noise.start(time); | |
| noise.stop(time + duration); | |
| } | |
| playDubGuitarLick(time, p, destination, octaveShift = 0, gate = 1) { | |
| const scale = SCALES[p.scale]; | |
| const lickPattern = [0, 7, 5, 3, 5, 2, 0]; | |
| const lickSpeed = 0.08 * gate; | |
| const stopTime = time + (lickPattern.length * lickSpeed) + 0.1; | |
| lickPattern.forEach((noteIndex, i) => { | |
| const noteOffset = scale[noteIndex % scale.length]; | |
| const finalNote = p.rootNote + noteOffset + (octaveShift * 12); | |
| const freq = 440 * Math.pow(2, (finalNote - 69) / 12); | |
| const noteTime = time + i * lickSpeed; | |
| const carrier = this.audioCtx.createOscillator(); | |
| carrier.type = 'sine'; | |
| carrier.frequency.value = freq; | |
| const modulator = this.audioCtx.createOscillator(); | |
| modulator.type = 'sine'; | |
| modulator.frequency.value = freq * 1.414; | |
| const modulatorGain = this.audioCtx.createGain(); | |
| modulatorGain.gain.setValueAtTime(freq * 1.5, noteTime); | |
| modulatorGain.gain.exponentialRampToValueAtTime(0.001, noteTime + 0.02); | |
| const amp = this.audioCtx.createGain(); | |
| amp.gain.setValueAtTime(0, noteTime); | |
| amp.gain.linearRampToValueAtTime(0.3, noteTime + 0.005); | |
| amp.gain.exponentialRampToValueAtTime(0.001, noteTime + 0.1); | |
| modulator.connect(modulatorGain); | |
| modulatorGain.connect(carrier.frequency); | |
| carrier.connect(amp); | |
| amp.connect(destination); | |
| carrier.start(noteTime); | |
| modulator.start(noteTime); | |
| carrier.stop(stopTime); | |
| modulator.stop(stopTime); | |
| }); | |
| } | |
| playSynthFunkLick(time, p, destination, octaveShift = 0, gate = 1) { | |
| const scale = SCALES[p.scale]; | |
| const noteIndex = Math.floor(Math.random() * scale.length); | |
| const finalNote = p.rootNote + scale[noteIndex] + (octaveShift * 12); | |
| const freq = 440 * Math.pow(2, (finalNote - 69) / 12); | |
| const osc = this.audioCtx.createOscillator(); | |
| osc.type = 'sawtooth'; | |
| const startFreq = freq * p.pitchSweepAmount; | |
| osc.frequency.setValueAtTime(startFreq, time); | |
| osc.frequency.exponentialRampToValueAtTime(freq, time + p.pitchSweepTime * gate); | |
| const filter = this.audioCtx.createBiquadFilter(); | |
| filter.type = "lowpass"; | |
| filter.Q.value = p.filterQ; | |
| const filterEnvStartTime = time + p.filterAttack; | |
| filter.frequency.setValueAtTime(p.filterCutoff, time); | |
| filter.frequency.linearRampToValueAtTime(p.filterCutoff + p.filterEnvAmount, filterEnvStartTime); | |
| filter.frequency.setTargetAtTime(p.filterCutoff, filterEnvStartTime, p.filterDecay / 2); | |
| const amp = this.audioCtx.createGain(); | |
| const stopTime = time + p.attack + p.decay + p.release; | |
| amp.gain.setValueAtTime(0, time); | |
| amp.gain.linearRampToValueAtTime(0.4, time + p.attack); | |
| amp.gain.setTargetAtTime(0, time + p.attack, p.decay / 2); | |
| osc.connect(filter); | |
| filter.connect(amp); | |
| amp.connect(destination); | |
| osc.start(time); | |
| osc.stop(stopTime); | |
| } | |
| playCowbell(time, p, destination, octaveShift = 0, gate = 1) { | |
| const root = p.rootNote + (octaveShift * 12); | |
| const freq = 440 * Math.pow(2, (root - 69) / 12); | |
| const amp = this.audioCtx.createGain(); | |
| const filter = this.audioCtx.createBiquadFilter(); | |
| const stopTime = time + 0.15 * gate; | |
| filter.type = 'bandpass'; | |
| filter.frequency.value = 3000; | |
| filter.Q.value = 1.2; | |
| amp.gain.setValueAtTime(0, time); | |
| amp.gain.linearRampToValueAtTime(0.8, time + 0.005); | |
| amp.gain.exponentialRampToValueAtTime(0.001, stopTime); | |
| [freq * 0.66, freq].forEach(f => { | |
| const osc = this.audioCtx.createOscillator(); | |
| osc.type = 'square'; | |
| osc.frequency.value = f; | |
| osc.detune.value = (Math.random() - 0.5) * 15; | |
| osc.connect(filter); | |
| osc.start(time); | |
| osc.stop(stopTime); | |
| }); | |
| filter.connect(amp); | |
| amp.connect(destination); | |
| } | |
| playClave(time, p, destination, octaveShift = 0, gate = 1) { | |
| const root = p.rootNote + (octaveShift * 12); | |
| const freq = 440 * Math.pow(2, (root - 69) / 12); | |
| const amp = this.audioCtx.createGain(); | |
| const stopTime = time + 0.1 * gate; | |
| amp.gain.setValueAtTime(1.0, time); | |
| amp.gain.exponentialRampToValueAtTime(0.001, stopTime); | |
| const osc = this.audioCtx.createOscillator(); | |
| osc.type = 'triangle'; | |
| osc.frequency.setValueAtTime(freq * 2, time); | |
| osc.frequency.exponentialRampToValueAtTime(freq, time + 0.05); | |
| osc.connect(amp); | |
| amp.connect(destination); | |
| osc.start(time); | |
| osc.stop(stopTime); | |
| } | |
| playVibraphone(time, p, destination, octaveShift = 0, gate = 1) { | |
| const scale = SCALES[p.scale]; | |
| const noteIndex = Math.floor(Math.random() * scale.length); | |
| const finalNote = p.rootNote + scale[noteIndex] + (octaveShift * 12); | |
| const freq = 440 * Math.pow(2, (finalNote - 69) / 12); | |
| const stopTime = time + p.attack + p.decay + p.release; | |
| const amp = this.audioCtx.createGain(); | |
| amp.gain.setValueAtTime(0, time); | |
| amp.gain.linearRampToValueAtTime(0.5, time + p.attack); | |
| amp.gain.setTargetAtTime(0, time + p.attack + p.decay, p.release / 4); | |
| const carrier = this.audioCtx.createOscillator(); | |
| carrier.type = 'sine'; | |
| carrier.frequency.value = freq; | |
| const modulator = this.audioCtx.createOscillator(); | |
| modulator.type = 'sine'; | |
| modulator.frequency.value = freq * 2.7; | |
| const modulatorGain = this.audioCtx.createGain(); | |
| modulatorGain.gain.setValueAtTime(freq * 0.5, time); | |
| modulatorGain.gain.exponentialRampToValueAtTime(0.001, time + 0.1); | |
| const tremolo = this.audioCtx.createOscillator(); | |
| tremolo.type = 'sine'; | |
| tremolo.frequency.value = p.vibratoRate; | |
| const tremoloGain = this.audioCtx.createGain(); | |
| tremoloGain.gain.value = p.vibratoDepth; | |
| modulator.connect(modulatorGain); | |
| modulatorGain.connect(carrier.frequency); | |
| carrier.connect(amp); | |
| tremolo.connect(tremoloGain); | |
| tremoloGain.connect(amp.gain); | |
| amp.connect(destination); | |
| carrier.start(time); | |
| modulator.start(time); | |
| tremolo.start(time); | |
| carrier.stop(stopTime); | |
| modulator.stop(stopTime); | |
| tremolo.stop(stopTime); | |
| } | |
| playStab(time, p, destination, octaveShift = 0, gate = 1) { const scale = SCALES[p.scale]; const noteIndex = Math.floor(Math.random() * scale.length); const finalNote = p.rootNote + scale[noteIndex] + (octaveShift * 12); const freq = 440 * Math.pow(2, (finalNote - 69) / 12); const osc1 = this.audioCtx.createOscillator(); osc1.type = 'sawtooth'; osc1.frequency.value = freq; const osc2 = this.audioCtx.createOscillator(); osc2.type = 'square'; osc2.frequency.value = freq; osc2.detune.value = 5; const filter = this.audioCtx.createBiquadFilter(); filter.type = "lowpass"; filter.Q.value = p.filterQ; const amp = this.audioCtx.createGain(); const gateDuration = (60 / this.bpm) * gate; const releaseStartTime = time + gateDuration; const stopTime = releaseStartTime + p.release; amp.gain.setValueAtTime(0, time); amp.gain.linearRampToValueAtTime(0.3, time + p.attack); amp.gain.setTargetAtTime(p.sustain * 0.3, time + p.attack, p.decay / 3); amp.gain.cancelScheduledValues(releaseStartTime); amp.gain.setValueAtTime(amp.gain.value, releaseStartTime); amp.gain.exponentialRampToValueAtTime(0.001, stopTime); const filterAttackPeak = p.filterCutoff + p.filterEnvAmount; const filterSustainLevel = p.filterCutoff + (p.filterEnvAmount * p.filterSustain); filter.frequency.setValueAtTime(p.filterCutoff, time); filter.frequency.linearRampToValueAtTime(filterAttackPeak, time + p.filterAttack); filter.frequency.setTargetAtTime(filterSustainLevel, time + p.filterAttack, p.filterDecay / 3); filter.frequency.cancelScheduledValues(releaseStartTime); filter.frequency.setValueAtTime(filter.frequency.value, releaseStartTime); filter.frequency.exponentialRampToValueAtTime(p.filterCutoff, stopTime); osc1.connect(amp); osc2.connect(amp); amp.connect(filter); filter.connect(destination); osc1.start(time); osc2.start(time); osc1.stop(stopTime); osc2.stop(stopTime); } | |
| playDubChord(time, p, destination, octaveShift = 0, gate = 1) { let rootNote = p.rootNote; let notes; if (this.chordStasisMode === 'MEMORY' || this.chordStasisMode === 'LATCH') { if (this.memorizedChordIntervals) { notes = this.memorizedChordIntervals.map(i => rootNote + i); } else { let initialNotes = [p.rootNote, p.rootNote + 3, p.rootNote + 7, p.rootNote + 10, p.rootNote + 14]; const inversion = p.inversion; for (let i = 0; i < inversion; i++) { initialNotes.push(initialNotes.shift() + 12); } const voicing = p.voicing; if (voicing === 'spread') { initialNotes[1] += 12; initialNotes[3] += 12; } else if (voicing === 'drop2' && initialNotes.length >= 2) { initialNotes[1] -= 12; } this.memorizedChordIntervals = initialNotes.map(n => n - p.rootNote); notes = initialNotes; } } else { let standardNotes = [p.rootNote, p.rootNote + 3, p.rootNote + 7, p.rootNote + 10, p.rootNote + 14]; const inversion = p.inversion; for (let i = 0; i < inversion; i++) { standardNotes.push(standardNotes.shift() + 12); } const voicing = p.voicing; if (voicing === 'spread') { standardNotes[1] += 12; standardNotes[3] += 12; } else if (voicing === 'drop2' && standardNotes.length >= 2) { standardNotes[1] -= 12; } notes = standardNotes; } const isDroneNote = this.chordStasisMode === 'DRONE' && this.frozenChordNodes.length === 0; const createdNodes = []; const filter = this.audioCtx.createBiquadFilter(); filter.type = "lowpass"; filter.Q.value = p.filterQ; const amp = this.audioCtx.createGain(); const gateDuration = (60 / this.bpm) * gate; const releaseStartTime = time + gateDuration; const stopTime = releaseStartTime + p.release; amp.gain.setValueAtTime(0, time); amp.gain.linearRampToValueAtTime(0.15, time + p.attack); amp.gain.setTargetAtTime(p.sustain * 0.15, time + p.attack, p.decay / 3); const filterAttackPeak = p.filterCutoff + p.filterEnvAmount; const filterSustainLevel = p.filterCutoff + (p.filterEnvAmount * p.filterSustain); filter.frequency.setValueAtTime(p.filterCutoff, time); filter.frequency.linearRampToValueAtTime(filterAttackPeak, time + p.filterAttack); filter.frequency.setTargetAtTime(filterSustainLevel, time + p.filterAttack, p.filterDecay / 3); if (!isDroneNote) { amp.gain.cancelScheduledValues(releaseStartTime); amp.gain.setValueAtTime(amp.gain.value, releaseStartTime); amp.gain.exponentialRampToValueAtTime(0.001, stopTime); filter.frequency.cancelScheduledValues(releaseStartTime); filter.frequency.setValueAtTime(filter.frequency.value, releaseStartTime); filter.frequency.exponentialRampToValueAtTime(p.filterCutoff, stopTime); } else { const lfoGain = this.audioCtx.createGain(); lfoGain.gain.value = 100; this.chordFilterLFO.connect(lfoGain); lfoGain.connect(filter.frequency); createdNodes.push({ type: 'lfo', nodes: [lfoGain], parent: this.chordFilterLFO}); } notes.forEach(note => { const finalNote = note + (octaveShift * 12); const freq = 440 * Math.pow(2, (finalNote - 69) / 12); const osc1 = this.audioCtx.createOscillator(); osc1.type = 'sawtooth'; osc1.frequency.value = freq; osc1.detune.value = -5; const osc2 = this.audioCtx.createOscillator(); osc2.type = 'sawtooth'; osc2.frequency.value = freq; osc2.detune.value = 5; osc1.start(time); osc2.start(time); if (isDroneNote) { const detuneLfoGain = this.audioCtx.createGain(); detuneLfoGain.gain.value = 3; this.chordDetuneLFO.connect(detuneLfoGain); detuneLfoGain.connect(osc1.detune); detuneLfoGain.connect(osc2.detune); createdNodes.push({ type: 'osc', nodes: [osc1, osc2]}); createdNodes.push({ type: 'lfo', nodes: [detuneLfoGain], parent: this.chordDetuneLFO}); } else { osc1.stop(stopTime); osc2.stop(stopTime); } osc1.connect(amp); osc2.connect(amp); }); amp.connect(filter); filter.connect(destination); if (isDroneNote) { this.frozenChordNodes.push(...createdNodes); } } | |
| playDryChord(time, p, destination, octaveShift = 0, gate = 1) { const rootNote = p.rootNote + (octaveShift * 12); const notes = [rootNote, rootNote + 3, rootNote + 7]; const amp = this.audioCtx.createGain(); const filter = this.audioCtx.createBiquadFilter(); filter.type = 'lowpass'; filter.Q.value = p.filterQ; const gateDuration = (60 / this.bpm) * gate; const releaseStartTime = time + gateDuration; const stopTime = releaseStartTime + p.release; amp.gain.setValueAtTime(0, time); amp.gain.linearRampToValueAtTime(0.2, time + p.attack); amp.gain.setTargetAtTime(p.sustain * 0.2, time + p.attack, p.decay / 3); amp.gain.cancelScheduledValues(releaseStartTime); amp.gain.setValueAtTime(amp.gain.value, releaseStartTime); amp.gain.exponentialRampToValueAtTime(0.001, stopTime); const filterAttackPeak = p.filterCutoff + p.filterEnvAmount; const filterSustainLevel = p.filterCutoff + (p.filterEnvAmount * p.filterSustain); filter.frequency.setValueAtTime(p.filterCutoff, time); filter.frequency.linearRampToValueAtTime(filterAttackPeak, time + p.filterAttack); filter.frequency.setTargetAtTime(filterSustainLevel, time + p.filterAttack, p.filterDecay / 3); filter.frequency.cancelScheduledValues(releaseStartTime); filter.frequency.setValueAtTime(filter.frequency.value, releaseStartTime); filter.frequency.exponentialRampToValueAtTime(p.filterCutoff, stopTime); notes.forEach(note => { const freq = 440 * Math.pow(2, (note - 69) / 12); const osc = this.audioCtx.createOscillator(); osc.type = 'sawtooth'; osc.frequency.value = freq; osc.connect(amp); osc.start(time); osc.stop(stopTime); }); amp.connect(filter); filter.connect(destination); } | |
| playReeseBass(time, p, destination, octaveShift = 0, gate = 1) { const finalNote = p.rootNote + (octaveShift * 12); const freq = 440 * Math.pow(2, (finalNote - 69) / 12); const osc1 = this.audioCtx.createOscillator(); osc1.type = 'sawtooth'; osc1.frequency.value = freq; osc1.detune.value = -p.detune; const osc2 = this.audioCtx.createOscillator(); osc2.type = 'sawtooth'; osc2.frequency.value = freq; osc2.detune.value = p.detune; const filter = this.audioCtx.createBiquadFilter(); filter.type = 'lowpass'; filter.Q.value = p.filterQ; filter.frequency.value = p.filterCutoff; const amp = this.audioCtx.createGain(); const gateDuration = (60 / this.bpm) * gate; const releaseStartTime = time + gateDuration; const stopTime = releaseStartTime + p.release; amp.gain.setValueAtTime(0, time); amp.gain.linearRampToValueAtTime(0.4, time + p.attack); amp.gain.setTargetAtTime(p.sustain * 0.4, time + p.attack, p.decay / 3); amp.gain.cancelScheduledValues(releaseStartTime); amp.gain.setValueAtTime(amp.gain.value, releaseStartTime); amp.gain.exponentialRampToValueAtTime(0.001, stopTime); osc1.connect(filter); osc2.connect(filter); filter.connect(amp); amp.connect(destination); osc1.start(time); osc2.start(time); osc1.stop(stopTime); osc2.stop(stopTime); } | |
| playSubBass(time, p, destination, octaveShift = 0, gate = 1) { const finalNote = p.rootNote + SCALES[p.scale][0] + (octaveShift * 12); const freq = 440 * Math.pow(2, (finalNote - 69) / 12); const osc = this.audioCtx.createOscillator(); osc.type = p.oscillatorType; osc.frequency.value = freq; const filter = this.audioCtx.createBiquadFilter(); filter.type = 'lowpass'; filter.Q.value = p.filterQ; filter.frequency.setValueAtTime(p.filterCutoff, time); const lfo = this.audioCtx.createOscillator(); lfo.type = p.lfoShape; lfo.frequency.value = this.bpm / (60 * (32 / p.lfoRate)); const lfoGain = this.audioCtx.createGain(); lfoGain.gain.value = p.lfoDepth; lfo.connect(lfoGain); lfoGain.connect(filter.frequency); const amp = this.audioCtx.createGain(); const gateDuration = (60 / this.bpm) * gate; const releaseStartTime = time + gateDuration; const stopTime = releaseStartTime + p.release; amp.gain.setValueAtTime(0, time); amp.gain.linearRampToValueAtTime(1.0, time + p.attack); amp.gain.setTargetAtTime(p.sustain, time + p.attack, p.decay / 3); amp.gain.cancelScheduledValues(releaseStartTime); amp.gain.setValueAtTime(amp.gain.value, releaseStartTime); amp.gain.exponentialRampToValueAtTime(0.001, stopTime); osc.connect(filter); filter.connect(amp); amp.connect(destination); osc.start(time); osc.stop(stopTime); lfo.start(time); lfo.stop(stopTime); } | |
| playEskiBass(time, p, destination, octaveShift = 0, gate = 1) { const finalNote = p.rootNote + (octaveShift * 12); const freq = 440 * Math.pow(2, (finalNote - 69) / 12); const osc1 = this.audioCtx.createOscillator(); osc1.type = 'square'; osc1.detune.value = -p.detune; const osc2 = this.audioCtx.createOscillator(); osc2.type = 'square'; osc2.detune.value = p.detune; const amp = this.audioCtx.createGain(); const gateDuration = (60 / this.bpm) * gate; const releaseStartTime = time + gateDuration; const stopTime = releaseStartTime + p.release; osc1.frequency.setValueAtTime(freq * (1 - p.glide), time); osc1.frequency.exponentialRampToValueAtTime(freq, time + 0.05); osc2.frequency.setValueAtTime(freq * (1 - p.glide), time); osc2.frequency.exponentialRampToValueAtTime(freq, time + 0.05); amp.gain.setValueAtTime(0, time); amp.gain.linearRampToValueAtTime(0.8, time + p.attack); amp.gain.setTargetAtTime(p.sustain * 0.8, time + p.attack, p.decay / 3); amp.gain.cancelScheduledValues(releaseStartTime); amp.gain.setValueAtTime(amp.gain.value, releaseStartTime); amp.gain.exponentialRampToValueAtTime(0.001, stopTime); osc1.connect(amp); osc2.connect(amp); amp.connect(destination); osc1.start(time); osc2.start(time); osc1.stop(stopTime); osc2.stop(stopTime); } | |
| playLead(time, p, destination, octaveShift = 0, gate = 1) { const scale = SCALES[p.scale]; const noteIndex = randomInt(scale.length); const finalNote = p.rootNote + scale[noteIndex] + (octaveShift * 12); const freq = 440 * Math.pow(2, (finalNote - 69) / 12); const osc1 = this.audioCtx.createOscillator(); osc1.type = 'square'; const osc2 = this.audioCtx.createOscillator(); osc2.type = 'sawtooth'; osc2.detune.value = 7; const osc2Gain = this.audioCtx.createGain(); osc2Gain.gain.value = 0.5; const lfo = this.audioCtx.createOscillator(); lfo.type = 'sine'; lfo.frequency.value = p.vibratoRate + Math.random() * 2; const lfoGain = this.audioCtx.createGain(); lfoGain.gain.value = p.vibratoDepth + Math.random() * 3; lfo.connect(lfoGain); lfoGain.connect(osc1.detune); lfoGain.connect(osc2.detune); const filter = this.audioCtx.createBiquadFilter(); filter.type = 'lowpass'; filter.Q.value = p.filterQ; filter.frequency.value = p.filterCutoff; const amp = this.audioCtx.createGain(); const gateDuration = (60 / this.bpm) * gate; const releaseStartTime = time + gateDuration; const stopTime = releaseStartTime + p.release; osc1.frequency.setValueAtTime(freq, time); osc2.frequency.setValueAtTime(freq, time); amp.gain.setValueAtTime(0, time); amp.gain.linearRampToValueAtTime(0.3, time + p.attack); amp.gain.setTargetAtTime(p.sustain, time + p.attack, p.decay / 3); amp.gain.cancelScheduledValues(releaseStartTime); amp.gain.setValueAtTime(amp.gain.value, releaseStartTime); amp.gain.exponentialRampToValueAtTime(0.001, stopTime); osc1.connect(filter); osc2.connect(osc2Gain); osc2Gain.connect(filter); filter.connect(amp); amp.connect(destination); osc1.start(time); osc2.start(time); lfo.start(time); osc1.stop(stopTime); osc2.stop(stopTime); lfo.stop(stopTime); } | |
| playShortPad(time, p, destination, octaveShift = 0, gate = 1) { const isDroneNote = this.chordStasisMode === 'DRONE' && this.frozenChordNodes.length === 0; const scale = SCALES[p.scale]; const finalNote = p.rootNote + scale[randomInt(scale.length)] + (octaveShift * 12); const freq = 440 * Math.pow(2, (finalNote - 69) / 12); const createdNodes = []; const filter = this.audioCtx.createBiquadFilter(); filter.type = 'lowpass'; filter.Q.value = p.filterQ; const amp = this.audioCtx.createGain(); const gateDuration = (60 / this.bpm) * gate; const releaseStartTime = time + gateDuration; const stopTime = releaseStartTime + p.release; amp.gain.setValueAtTime(0, time); amp.gain.linearRampToValueAtTime(0.15, time + p.attack); amp.gain.setTargetAtTime(p.sustain * 0.15, time + p.attack, p.decay / 3); const filterAttackPeak = p.filterCutoff + p.filterEnvAmount; const filterSustainLevel = p.filterCutoff + (p.filterEnvAmount * p.filterSustain); filter.frequency.setValueAtTime(p.filterCutoff, time); filter.frequency.linearRampToValueAtTime(filterAttackPeak, time + p.filterAttack); filter.frequency.setTargetAtTime(filterSustainLevel, time + p.filterAttack, p.filterDecay / 3); if (!isDroneNote) { amp.gain.cancelScheduledValues(releaseStartTime); amp.gain.setValueAtTime(amp.gain.value, releaseStartTime); amp.gain.exponentialRampToValueAtTime(0.001, stopTime); filter.frequency.cancelScheduledValues(releaseStartTime); filter.frequency.setValueAtTime(filter.frequency.value, releaseStartTime); filter.frequency.exponentialRampToValueAtTime(p.filterCutoff, stopTime); } else { createdNodes.push({ type: 'lfo', nodes: [], parent: null }); } ['-7', '0', '7'].forEach(detune => { const osc = this.audioCtx.createOscillator(); osc.type = 'sawtooth'; osc.frequency.value = freq; osc.detune.value = parseInt(detune); osc.start(time); if (!isDroneNote) { osc.stop(stopTime); } else { createdNodes.push({ type: 'osc', nodes: [osc] }); } osc.connect(amp); }); amp.connect(filter); filter.connect(destination); if (isDroneNote) { this.frozenChordNodes.push(...createdNodes); } } | |
| playDeepPad(time, p, destination, octaveShift = 0, gate = 1) { const isDroneNote = this.chordStasisMode === 'DRONE' && this.frozenChordNodes.length === 0; const scale = SCALES[p.scale]; const finalNote = p.rootNote + scale[randomInt(scale.length)] + (octaveShift * 12); const freq = 440 * Math.pow(2, (finalNote - 69) / 12); const osc = this.audioCtx.createOscillator(); osc.type = 'sawtooth'; osc.frequency.value = freq; const filter = this.audioCtx.createBiquadFilter(); filter.type = 'lowpass'; filter.Q.value = p.filterQ; const amp = this.audioCtx.createGain(); const gateDuration = (60 / this.bpm) * 4 * gate; const releaseStartTime = time + gateDuration; const stopTime = releaseStartTime + p.release; const lfo = this.audioCtx.createOscillator(); lfo.type = 'sine'; lfo.frequency.value = 0.2; const lfoGain = this.audioCtx.createGain(); lfoGain.gain.value = freq * 0.5; lfo.connect(lfoGain); lfoGain.connect(filter.frequency); amp.gain.setValueAtTime(0, time); amp.gain.linearRampToValueAtTime(0.2, time + p.attack); amp.gain.setTargetAtTime(p.sustain * 0.2, time + p.attack, p.decay / 3); const filterAttackPeak = p.filterCutoff + p.filterEnvAmount; const filterSustainLevel = p.filterCutoff + (p.filterEnvAmount * p.filterSustain); filter.frequency.setValueAtTime(p.filterCutoff, time); filter.frequency.linearRampToValueAtTime(filterAttackPeak, time + p.filterAttack); filter.frequency.setTargetAtTime(filterSustainLevel, time + p.filterAttack, p.filterDecay / 3); lfo.start(time); osc.start(time); if(!isDroneNote) { amp.gain.cancelScheduledValues(releaseStartTime); amp.gain.setValueAtTime(amp.gain.value, releaseStartTime); amp.gain.exponentialRampToValueAtTime(0.0001, stopTime); filter.frequency.cancelScheduledValues(releaseStartTime); filter.frequency.setValueAtTime(filter.frequency.value, releaseStartTime); filter.frequency.exponentialRampToValueAtTime(p.filterCutoff, stopTime); lfo.stop(stopTime); osc.stop(stopTime); } else { amp.gain.linearRampToValueAtTime(0.2, time + 0.5); this.frozenChordNodes.push({ type: 'osc', nodes: [osc]}, { type: 'lfo', nodes: [lfoGain], parent: lfo}); } osc.connect(filter); filter.connect(amp); amp.connect(destination); } | |
| playNoise(time, p, destination, pitchMultiplier, octaveShift = 0, gate = 1) { const finalPitch = pitchMultiplier * Math.pow(2, octaveShift); const duration = 0.1 * p.decay * gate; const gain = this.audioCtx.createGain(); const noise = this.audioCtx.createBufferSource(); const buffer = this.audioCtx.createBuffer(1, this.audioCtx.sampleRate * duration, this.audioCtx.sampleRate); const data = buffer.getChannelData(0); for (let i = 0; i < data.length; i++) data[i] = Math.random() * 2 - 1; noise.buffer = buffer; const bandpass = this.audioCtx.createBiquadFilter(); bandpass.type = 'bandpass'; bandpass.Q.value = 1; bandpass.frequency.value = 5000 * finalPitch; noise.connect(bandpass); bandpass.connect(gain); gain.connect(destination); gain.gain.setValueAtTime(0.4, time); gain.gain.exponentialRampToValueAtTime(0.001, time + duration); noise.start(time); noise.stop(time + duration); } | |
| playSiren(time, p, destination, octaveShift = 0, gate = 1) { const lfoRate = p.lfoRate; const lfoDepth = p.lfoDepth; const pitchBend = p.pitchBend; const tone = p.tone; const finalNote = p.rootNote + (octaveShift * 12); const startFreq = 440 * Math.pow(2, (finalNote - 69) / 12); const endFreq = Math.max(0.001, startFreq * (1 - pitchBend)); const osc = this.audioCtx.createOscillator(); osc.type = 'sawtooth'; const lfo = this.audioCtx.createOscillator(); lfo.type = 'sine'; lfo.frequency.value = lfoRate; const lfoGain = this.audioCtx.createGain(); lfoGain.gain.value = lfoDepth; lfo.connect(lfoGain); lfoGain.connect(osc.frequency); const filter = this.audioCtx.createBiquadFilter(); filter.type = 'lowpass'; filter.Q.value = 3; filter.frequency.value = tone; const amp = this.audioCtx.createGain(); const duration = ((60 / this.bpm) * 4) * gate; osc.frequency.setValueAtTime(startFreq, time); osc.frequency.exponentialRampToValueAtTime(endFreq, time + duration * 0.8); amp.gain.setValueAtTime(0, time); amp.gain.linearRampToValueAtTime(0.5, time + p.attack); amp.gain.setValueAtTime(0.5, time + duration - p.release); amp.gain.exponentialRampToValueAtTime(0.001, time + duration); osc.connect(filter); filter.connect(amp); amp.connect(destination); osc.start(time); osc.stop(time + duration); lfo.start(time); lfo.stop(time + duration); } | |
| playAggroSiren(time, p, destination, octaveShift = 0, gate = 1) { const finalNote = p.rootNote + (octaveShift * 12); const baseFreq = 440 * Math.pow(2, (finalNote - 69) / 12); const osc = this.audioCtx.createOscillator(); osc.type = p.oscillatorType; osc.frequency.value = baseFreq; const lfo = this.audioCtx.createOscillator(); lfo.type = 'square'; lfo.frequency.value = p.lfoRate; const lfoGain = this.audioCtx.createGain(); lfoGain.gain.value = p.lfoDepth; lfo.connect(lfoGain); lfoGain.connect(osc.frequency); const amp = this.audioCtx.createGain(); const duration = ((60 / this.bpm) * 4) * gate; amp.gain.setValueAtTime(0, time); amp.gain.linearRampToValueAtTime(0.4, time + p.attack); amp.gain.setValueAtTime(0.4, time + duration - p.release); amp.gain.exponentialRampToValueAtTime(0.001, time + duration); osc.connect(amp); amp.connect(destination); osc.start(time); osc.stop(time + duration); lfo.start(time); lfo.stop(time + duration); } | |
| playTuningTone(time, p, destination, octaveShift = 0, gate = 1) { const finalNote = p.rootNote + (octaveShift * 12); const freq = 440 * Math.pow(2, (finalNote - 69) / 12); const osc = this.audioCtx.createOscillator(); osc.type = 'sine'; const lfo = this.audioCtx.createOscillator(); lfo.type = 'sine'; lfo.frequency.value = 0.2; const lfoGain = this.audioCtx.createGain(); lfoGain.gain.value = 2; lfo.connect(lfoGain); lfoGain.connect(osc.detune); osc.frequency.setValueAtTime(freq * 1.01, time); osc.frequency.linearRampToValueAtTime(freq, time + 0.05); const amp = this.audioCtx.createGain(); const duration = ((60 / this.bpm) * 4) * gate; const releaseStartTime = time + duration; const stopTime = releaseStartTime + p.release; amp.gain.setValueAtTime(0, time); amp.gain.linearRampToValueAtTime(1.0, time + p.attack); amp.gain.setTargetAtTime(p.sustain, time + p.attack, p.decay); amp.gain.cancelScheduledValues(releaseStartTime); amp.gain.setValueAtTime(amp.gain.value, releaseStartTime); amp.gain.exponentialRampToValueAtTime(0.001, stopTime); osc.connect(amp); amp.connect(destination); lfo.start(time); osc.start(time); osc.stop(stopTime); lfo.stop(stopTime); } | |
| playManualSweep(time, p, destination, octaveShift = 0, gate = 1) { const finalNote = p.rootNote + (octaveShift * 12); const startFreq = 440 * Math.pow(2, (finalNote - 69) / 12); const endFreq = startFreq * p.endPitchMultiplier; const osc = this.audioCtx.createOscillator(); osc.type = 'sawtooth'; const amp = this.audioCtx.createGain(); const duration = p.sweepTime * gate; osc.frequency.setValueAtTime(startFreq, time); osc.frequency.exponentialRampToValueAtTime(endFreq, time + duration * 0.95); amp.gain.setValueAtTime(0, time); amp.gain.linearRampToValueAtTime(0.5, time + p.attack); amp.gain.setValueAtTime(0.5, time + duration - p.release); amp.gain.exponentialRampToValueAtTime(0.001, time + duration); osc.connect(amp); amp.connect(destination); osc.start(time); osc.stop(time + duration); } | |
| playSfxSweep(time, destination, pitchMultiplier, octaveShift = 0, gate = 1) { const finalPitch = pitchMultiplier * Math.pow(2, octaveShift); const beatDuration = 60.0 / this.bpm; const duration = (beatDuration * 8) * gate; if (duration <= 0) return; const gain = this.audioCtx.createGain(); const noise = this.audioCtx.createBufferSource(); const buffer = this.audioCtx.createBuffer(1, this.audioCtx.sampleRate * duration, this.audioCtx.sampleRate); const data = buffer.getChannelData(0); for (let i = 0; i < data.length; i++) data[i] = Math.random() * 2 - 1; noise.buffer = buffer; const bandpass = this.audioCtx.createBiquadFilter(); bandpass.type = 'bandpass'; bandpass.Q.value = 8; bandpass.frequency.setValueAtTime(200 * finalPitch, time); bandpass.frequency.exponentialRampToValueAtTime(8000 * finalPitch, time + duration * 0.95); noise.connect(bandpass); bandpass.connect(gain); gain.connect(destination); gain.gain.setValueAtTime(0.001, time); gain.gain.exponentialRampToValueAtTime(0.7, time + duration * 0.9); gain.gain.linearRampToValueAtTime(0.001, time + duration); noise.start(time); noise.stop(time + duration); } | |
| playSfxRiser(time, destination, pitchMultiplier, octaveShift = 0, gate = 1) { const finalPitch = pitchMultiplier * Math.pow(2, octaveShift); const duration = ((60 / this.bpm) * 4) * gate; const gain = this.audioCtx.createGain(); const noise = this.audioCtx.createBufferSource(); const buffer = this.audioCtx.createBuffer(1, this.audioCtx.sampleRate * duration, this.audioCtx.sampleRate); const data = buffer.getChannelData(0); for (let i = 0; i < data.length; i++) data[i] = Math.random() * 2 - 1; noise.buffer = buffer; const hipass = this.audioCtx.createBiquadFilter(); hipass.type = 'highpass'; hipass.Q.value = 3; hipass.frequency.setValueAtTime(200 * finalPitch, time); hipass.frequency.exponentialRampToValueAtTime(10000 * finalPitch, time + duration * 0.95); noise.connect(hipass); hipass.connect(gain); gain.connect(destination); gain.gain.setValueAtTime(0.001, time); gain.gain.exponentialRampToValueAtTime(0.8, time + duration * 0.9); gain.gain.linearRampToValueAtTime(0, time + duration); noise.start(time); noise.stop(time + duration); } | |
| playSfxZap(time, p, destination, pitchMultiplier, octaveShift = 0, gate = 1) { const finalPitch = pitchMultiplier * Math.pow(2, octaveShift); const osc = this.audioCtx.createOscillator(); const gain = this.audioCtx.createGain(); const duration = 0.15 * p.decay * gate; osc.type = 'triangle'; osc.frequency.setValueAtTime(2000 * finalPitch, time); osc.frequency.exponentialRampToValueAtTime(50 * finalPitch, time + duration); gain.gain.setValueAtTime(0.5, time); gain.gain.exponentialRampToValueAtTime(0.001, time + duration); osc.connect(gain); gain.connect(destination); osc.start(time); osc.stop(time + duration); } | |
| playSfxClink(time, p, destination, pitchMultiplier, octaveShift = 0, gate = 1) { const finalPitch = pitchMultiplier * Math.pow(2, octaveShift); const gain = this.audioCtx.createGain(); const duration = 0.05 * p.decay * gate; const noise = this.audioCtx.createBufferSource(); const buffer = this.audioCtx.createBuffer(1, this.audioCtx.sampleRate * duration, this.audioCtx.sampleRate); const data = buffer.getChannelData(0); for (let i = 0; i < data.length; i++) data[i] = Math.random() * 2 - 1; noise.buffer = buffer; const bandpass = this.audioCtx.createBiquadFilter(); bandpass.type = 'bandpass'; bandpass.frequency.value = 6000 * finalPitch; bandpass.Q.value = 5; noise.connect(bandpass); bandpass.connect(gain); gain.connect(destination); gain.gain.setValueAtTime(0.6, time); gain.gain.exponentialRampToValueAtTime(0.001, time + duration); noise.start(time); noise.stop(time + duration); } | |
| playMelodica(time, p, destination, octaveShift = 0, gate = 1) { const scale = SCALES[p.scale]; const noteIndex = randomInt(scale.length); const finalNote = p.rootNote + scale[noteIndex] + (octaveShift * 12); const freq = 440 * Math.pow(2, (finalNote - 69) / 12); const osc1 = this.audioCtx.createOscillator(); osc1.type = 'sawtooth'; osc1.frequency.value = freq; osc1.detune.value = -p.detune; const osc2 = this.audioCtx.createOscillator(); osc2.type = 'sawtooth'; osc2.frequency.value = freq; osc2.detune.value = p.detune; const filter = this.audioCtx.createBiquadFilter(); filter.type = 'lowpass'; filter.Q.value = p.filterQ; filter.frequency.value = p.filterCutoff; const amp = this.audioCtx.createGain(); const stopTime = time + p.attack + p.release; amp.gain.setValueAtTime(0, time); amp.gain.linearRampToValueAtTime(0.35, time + p.attack); amp.gain.exponentialRampToValueAtTime(0.001, stopTime); osc1.connect(filter); osc2.connect(filter); filter.connect(amp); amp.connect(destination); osc1.start(time); osc2.start(time); osc1.stop(stopTime); osc2.stop(stopTime); } | |
| playSynthFlute(time, p, destination, octaveShift = 0, gate = 1) { const scale = SCALES[p.scale]; const noteIndex = randomInt(scale.length); const finalNote = p.rootNote + scale[noteIndex] + (octaveShift * 12); const freq = 440 * Math.pow(2, (finalNote - 69) / 12); const amp = this.audioCtx.createGain(); const osc = this.audioCtx.createOscillator(); osc.type = 'sine'; osc.frequency.value = freq; const noise = this.audioCtx.createBufferSource(); const buffer = this.audioCtx.createBuffer(1, this.audioCtx.sampleRate * 1, this.audioCtx.sampleRate); const data = buffer.getChannelData(0); for (let i = 0; i < data.length; i++) data[i] = Math.random() * 2 - 1; noise.buffer = buffer; noise.loop = true; const noiseFilter = this.audioCtx.createBiquadFilter(); noiseFilter.type = 'bandpass'; noiseFilter.frequency.value = p.noiseTone; noiseFilter.Q.value = 1; const noiseGain = this.audioCtx.createGain(); noiseGain.gain.value = p.noiseAmount; const lfo = this.audioCtx.createOscillator(); lfo.type = 'sine'; lfo.frequency.value = p.vibratoRate; const lfoGain = this.audioCtx.createGain(); lfoGain.gain.value = p.vibratoDepth; const stopTime = time + p.attack + p.decay + p.release; lfo.connect(lfoGain); lfoGain.connect(osc.detune); amp.gain.setValueAtTime(0, time); amp.gain.linearRampToValueAtTime(0.4, time + p.attack); amp.gain.setTargetAtTime(p.sustain * 0.4, time + p.attack, p.decay / 2); amp.gain.setTargetAtTime(0, time + p.attack + p.decay, p.release / 3); noise.connect(noiseFilter); noiseFilter.connect(noiseGain); noiseGain.connect(amp); osc.connect(amp); amp.connect(destination); lfo.start(time); noise.start(time); osc.start(time); lfo.stop(stopTime); noise.stop(stopTime); osc.stop(stopTime); } | |
| setGlobalProbability(val) { this.globalProbability = val; } | |
| setTrackDrift(val) { this.trackDrift = val; } | |
| setGhostNoteProbability(val) { this.ghostNoteProbability = val; } | |
| setHissDensity(val) { if (this.hissGain) { this.hissGain.gain.setTargetAtTime(val, this.audioCtx.currentTime, 0.02); } } | |
| setHissTone(val) { if (this.hissFilter) this.hissFilter.frequency.setTargetAtTime(val, this.audioCtx.currentTime, 0.01); } | |
| setEvolveRate(val) { this.evolveRate = val; if(this.chordFilterLFO) this.chordFilterLFO.frequency.value = val; if(this.chordDetuneLFO) this.chordDetuneLFO.frequency.value = val * 1.3; } | |
| setReverbPreDelay(val) { if (this.reverbPreDelay) this.reverbPreDelay.delayTime.value = val; } | |
| setCrackleAmount(val) { this.crackleAmount = val; } | |
| setMasterDelaySend(val) { if(this.masterDelaySend) this.masterDelaySend.gain.setTargetAtTime(val, this.audioCtx.currentTime, 0.02); } | |
| setMasterReverbSend(val) { if(this.masterReverbSend) this.masterReverbSend.gain.setTargetAtTime(val, this.audioCtx.currentTime, 0.02); } | |
| setDelayPitchDrift(val) { if(this.delayPitchLfoGain) this.delayPitchLfoGain.gain.setTargetAtTime(val, this.audioCtx.currentTime, 0.02); } | |
| setDelayFeedback(val) { if (!this.delayFeedback) return; const feedbackValue = parseFloat(val); this.delayFeedback.gain.setTargetAtTime(feedbackValue, this.audioCtx.currentTime, 0.02); this.delay1FeedbackSaturator.curve = this.makeDistortionCurve(feedbackValue * 0.5); } | |
| setDelayLpf(val) { if(this.delayLpf) this.delayLpf.frequency.setTargetAtTime(val, this.audioCtx.currentTime, 0.02); } | |
| setDelayHpf(val) { if(this.delayHpf) this.delayHpf.frequency.setTargetAtTime(val, this.audioCtx.currentTime, 0.02); } | |
| toggleChordFreeze() { | |
| const modes = ['OFF', 'DRONE', 'MEMORY', 'LATCH']; | |
| const currentIndex = modes.indexOf(this.chordStasisMode); | |
| const nextIndex = (currentIndex + 1) % modes.length; | |
| this.chordStasisMode = modes[nextIndex]; | |
| if (this.chordStasisMode !== 'DRONE' && this.frozenChordNodes.length > 0) { | |
| const now = this.audioCtx.currentTime; | |
| this.frozenChordNodes.forEach(item => { | |
| if (item.type === 'lfo') { | |
| item.nodes.forEach(node => node.disconnect()); | |
| if(item.parent) { try { item.parent.stop(now + 0.5); } catch(e) {/* ignore */} } | |
| } else if (item.type === 'osc') { | |
| item.nodes.forEach(node => { try { node.stop(now + 0.5); } catch(e) {/* ignore */} }); | |
| } | |
| }); | |
| this.frozenChordNodes = []; | |
| } | |
| if (this.chordStasisMode === 'OFF' || this.chordStasisMode === 'DRONE') { | |
| this.memorizedChordIntervals = null; | |
| } | |
| return this.chordStasisMode; | |
| } | |
| setReduction(val) { if (!this.audioCtx) return; const now = this.audioCtx.currentTime; const ease = 0.05; const maxFreq = 15000; const minFreq = 400; const cutoff = maxFreq * Math.pow(minFreq / maxFreq, val); this.filter.frequency.setTargetAtTime(cutoff, now, ease); const maxThresh = -12; const minThresh = -40; const threshold = maxThresh + (minThresh - maxThresh) * val; this.compressor.threshold.setTargetAtTime(threshold, now, ease); const driveAmount = 0.05 + val * 0.2; this.analogGlueSaturator.curve = this.makeDistortionCurve(driveAmount); const maxAge = 22000; const minAge = 3000; const age = maxAge + (minAge - maxAge) * (val * val); this.tapeAgeFilter1.frequency.setTargetAtTime(age, now, ease); this.tapeAgeFilter2.frequency.setTargetAtTime(age, now, ease); } | |
| setSaturation(val) { if (!this.saturator) return; if (val < 0.01) { this.saturator.curve = null; } else { this.saturator.curve = this.makeDistortionCurve(val); } } | |
| setSaturationTone(val) { if (!this.preSaturationFilter) return; const now = this.audioCtx.currentTime; const targetValue = parseFloat(val); this.preSaturationFilter.frequency.cancelScheduledValues(now); this.preSaturationFilter.frequency.setValueAtTime(this.preSaturationFilter.frequency.value, now); this.preSaturationFilter.frequency.exponentialRampToValueAtTime(targetValue, now + 0.05); } | |
| setTapeAge(val) { if(!this.tapeAgeFilter1) return; this.tapeAgeFilter1.frequency.value = val; this.tapeAgeFilter2.frequency.value = val; } | |
| setWowFlutter(val) { if(!this.wowFlutterGain) return; this.wowFlutterGain.gain.value = val * 0.002; } | |
| setModFx(val) { if(!this.modFxWet) return; this.modFxWet.gain.value = val; this.modFxDry.gain.value = 1 - (val * 0.5); } | |
| setModFxType(type) { this.modFxType = type; if (type === 'phaser') { this.modFxSelect.gain.value = 1; this.flangerFeedback.disconnect(); this.phaser.connect(this.modFxWet); } else { this.modFxSelect.gain.value = 0; this.phaser.disconnect(); this.flangerDelay.connect(this.modFxWet); } } | |
| setSidechain(val) { this.sidechainAmount = val; } | |
| setFilterCutoff(val) { if (!this.filter) return; const now = this.audioCtx.currentTime; const targetValue = parseFloat(val); this.filter.frequency.cancelScheduledValues(now); this.filter.frequency.setValueAtTime(this.filter.frequency.value, now); this.filter.frequency.exponentialRampToValueAtTime(targetValue, now + 0.05); } | |
| setDelayAmount(val) { if (!this.delayWet) return; this.delayWet.gain.value = val; const beatDur = 60 / this.bpm; this.delay.delayTime.value = beatDur * 0.35; } | |
| setDelay2Amount(val) { if (!this.delay2Wet) return; this.delay2Wet.gain.value = val; const feedback = Math.min(0.95, val * 0.9); this.delay2Feedback.gain.value = feedback; this.delay2FeedbackSaturator.curve = this.makeDistortionCurve(feedback * 0.5); const beatDur = 60 / this.bpm; this.delay2.delayTime.value = beatDur * 0.25; } | |
| setReverbAmount(val) { if(this.reverbWet) this.reverbWet.gain.value = val; } | |
| } | |
| class Track { | |
| constructor(id, engine, uiManager) { | |
| this.id = id; this.engine = engine; this.uiManager = uiManager; | |
| this.active = true; this.solo = false; this.mute = false; | |
| this.synth = 'kick'; | |
| this.mode = 'GATE'; | |
| this.params = { | |
| steps: 16, pulses: 4, offset: 0, volume: 1.0, | |
| sequence: Array(16).fill(false), overrides: {}, | |
| stepParams: {}, | |
| modSend: 0.0, delaySend: 1.0, reverbSend: 1.0, | |
| fxBypass: false, allowGhostNotes: true, | |
| ...this.getDefaultSynthParams('kick') | |
| }; | |
| this.pulse = 0; | |
| this.sanitizeParams(); | |
| this.ui = this.createUI(); | |
| this.updateUI(); | |
| } | |
| sanitizeParams() { | |
| const s = this.params; | |
| if (s.sequence.length !== s.steps) { | |
| console.warn(`Track ${this.id} (${this.synth}) has mismatched steps (${s.steps}) and sequence length (${s.sequence.length}). Sanitizing.`); | |
| const oldSeq = s.sequence; | |
| const newSeq = Array(s.steps).fill(false); | |
| for(let i = 0; i < Math.min(oldSeq.length, newSeq.length); i++) { | |
| newSeq[i] = oldSeq[i]; | |
| } | |
| s.sequence = newSeq; | |
| } | |
| } | |
| getDefaultSynthParams(synthType) { | |
| const base = { | |
| scale: 'minor', | |
| rootNote: 60, | |
| useGlobalSwing: true, | |
| swingAmount: 0.1, | |
| }; | |
| const bassADSR = { attack: 0.01, decay: 0.25, sustain: 0.05, release: 0.15 }; | |
| const pluckADSR = { attack: 0.01, decay: 0.3, sustain: 0.1, release: 0.2 }; | |
| const shortPadADSR = { attack: 0.05, decay: 0.4, sustain: 0.2, release: 0.5 }; | |
| const padADSR = { attack: 0.2, decay: 1.0, sustain: 0.4, release: 1.5 }; | |
| const filterADSR = { filterAttack: 0.01, filterDecay: 0.2, filterSustain: 0.1, filterRelease: 0.3, filterCutoff: 12000, filterQ: 1, filterEnvAmount: 0 }; | |
| const percDecay = { decay: 1.0 }; | |
| const percTone = { tone: 4000 }; | |
| switch(synthType) { | |
| case 'kick': case 'kick909': return { ...base, ...percDecay }; | |
| case 'snare': case 'rimshot': case 'clap': return { ...base, ...percDecay, ...percTone }; | |
| case 'hat': case 'hat909': case 'shaker': return { ...base, ...percDecay }; | |
| case 'noise': case 'sfx_zap': case 'sfx_clink': return { ...base, ...percDecay }; | |
| case 'funkmute': return { ...base }; | |
| case 'cowbell': return { ...base, rootNote: 76 }; | |
| case 'clave': return { ...base, rootNote: 88 }; | |
| case 'funkstab': return { ...base, rootNote: 72 }; | |
| case 'dubguitarriff': return { ...base, ...pluckADSR, rootNote: 72, release: 0.3 }; | |
| case 'synthfunklick': return { ...base, ...pluckADSR, ...filterADSR, rootNote: 48, attack: 0.005, decay: 0.15, release: 0.1, filterCutoff: 1000, filterQ: 8, filterEnvAmount: 4000, filterDecay: 0.1, pitchSweepAmount: 3, pitchSweepTime: 0.08}; | |
| case 'vibraphone': return { ...base, ...padADSR, rootNote: 72, attack: 0.01, decay: 1.5, release: 1.0, vibratoRate: 6, vibratoDepth: 0.05 }; | |
| case 'stab': return { ...base, ...pluckADSR, ...filterADSR, rootNote: 60, filterCutoff: 1200, filterQ: 4, filterEnvAmount: 3500 }; | |
| case 'drychord': return { ...base, ...pluckADSR, ...filterADSR, rootNote: 60, filterCutoff: 2500, filterQ: 3, filterEnvAmount: 2000 }; | |
| case 'dubchord': return { ...base, ...shortPadADSR, ...filterADSR, filterRelease: 0.8, rootNote: 48, filterCutoff: 800, filterQ: 4, filterEnvAmount: 1500, voicing: 'stacked', inversion: 0 }; | |
| case 'shortpad': return { ...base, ...shortPadADSR, ...filterADSR, rootNote: 50, filterCutoff: 1500, filterQ: 5, filterEnvAmount: 2500 }; | |
| case 'pad': return { ...base, ...padADSR, ...filterADSR, filterAttack: 0.5, filterDecay: 1.5, filterRelease: 2.0, rootNote: 38, filterCutoff: 600, filterQ: 4, filterEnvAmount: 1200 }; | |
| case 'subbass': return { ...base, ...bassADSR, rootNote: 36, filterCutoff: 350, filterQ: 1, lfoShape: 'sine', lfoRate: 8, lfoDepth: 0, oscillatorType: 'sine' }; | |
| case 'reese': return { ...base, ...bassADSR, attack: 0.05, sustain: 0.3, release: 0.4, rootNote: 28, filterCutoff: 1200, filterQ: 4, detune: 12 }; | |
| case 'eskibass': return {...base, ...bassADSR, attack: 0.005, decay: 0.15, rootNote: 48, detune: 8, glide: 0.1 }; | |
| case 'lead': return { ...base, ...pluckADSR, release: 0.25, rootNote: 60, filterCutoff: 1250, filterQ: 5.0, vibratoRate: 6, vibratoDepth: 5 }; | |
| case 'melodica': return { ...base, ...pluckADSR, attack: 0.02, sustain: 0.4, release: 0.25, rootNote: 72, filterCutoff: 2500, filterQ: 2.5, detune: 7 }; | |
| case 'synthflute': return { ...base, attack: 0.05, decay: 0.3, sustain: 0.3, release: 0.2, rootNote: 84, vibratoRate: 5, vibratoDepth: 4, noiseAmount: 0.15, noiseTone: 5000 }; | |
| case 'siren': return {...base, rootNote: 78, attack: 0.1, release: 0.5, lfoRate: 8, lfoDepth: 150, pitchBend: 0.6, tone: 2500 }; | |
| case 'siren_aggro': return { ...base, rootNote: 84, attack: 0.01, release: 0.2, oscillatorType: 'square', lfoRate: 12, lfoDepth: 400 }; | |
| case 'sfx_tuningtone': return { ...base, ...pluckADSR, attack: 0.005, decay: 0.2, sustain: 0.1, release: 0.4, rootNote: 72 }; | |
| case 'sfx_manualsweep': return { ...base, rootNote: 96, attack: 0.01, release: 0.1, sweepTime: 2.0, endPitchMultiplier: 0.1 }; | |
| default: return base; | |
| } | |
| } | |
| createUI() { | |
| const trackEl = document.createElement('div'); trackEl.className = 'track active'; trackEl.dataset.id = this.id; | |
| const controlsEl = document.createElement('div'); controlsEl.className = 'track-controls'; | |
| const nameBtn = document.createElement('button'); nameBtn.className = 'track-name-btn'; nameBtn.textContent = `T${this.id}`; | |
| nameBtn.onclick = () => this.randomize(); nameBtn.title = "Click: Randomize\nRight-Click: Edit"; | |
| nameBtn.oncontextmenu = (e) => { e.preventDefault(); this.uiManager.openModal(this); }; | |
| controlsEl.appendChild(nameBtn); | |
| const soloBtn = document.createElement('button'); soloBtn.textContent = 'S'; soloBtn.className = 'solo-btn'; soloBtn.title = 'Solo'; | |
| soloBtn.onclick = () => { this.solo = !this.solo; this.uiManager.updateTrackSoloMuteVisuals(); }; | |
| controlsEl.appendChild(soloBtn); | |
| const muteBtn = document.createElement('button'); muteBtn.textContent = 'M'; muteBtn.className = 'mute-btn'; muteBtn.title = 'Mute'; | |
| muteBtn.onclick = () => { this.mute = !this.mute; this.uiManager.updateTrackSoloMuteVisuals(); }; | |
| controlsEl.appendChild(muteBtn); | |
| const bypassBtn = document.createElement('button'); bypassBtn.textContent = 'B'; bypassBtn.className = 'bypass-btn'; bypassBtn.title = 'Bypass FX'; | |
| bypassBtn.onclick = () => { this.params.fxBypass = !this.params.fxBypass; this.uiManager.updateTrackSoloMuteVisuals(); }; | |
| controlsEl.appendChild(bypassBtn); | |
| const shuffleBtn = document.createElement('button'); | |
| shuffleBtn.textContent = 'SFL'; | |
| shuffleBtn.className = 'shuffle-params-btn'; | |
| shuffleBtn.title = 'Shuffle Step Octaves'; | |
| shuffleBtn.onclick = () => this.shuffleOctaves(); | |
| controlsEl.appendChild(shuffleBtn); | |
| const clearBtn = document.createElement('button'); | |
| clearBtn.textContent = 'CLR'; | |
| clearBtn.className = 'clear-params-btn'; | |
| clearBtn.title = 'Clear All Step Parameters'; | |
| clearBtn.onclick = () => this.clearStepParams(); | |
| controlsEl.appendChild(clearBtn); | |
| const duplicateBtn = document.createElement('button'); | |
| duplicateBtn.textContent = 'D'; | |
| duplicateBtn.className = 'duplicate-btn'; | |
| duplicateBtn.title = 'Duplicate'; | |
| duplicateBtn.onclick = () => this.uiManager.duplicateTrack(this.id); | |
| controlsEl.appendChild(duplicateBtn); | |
| const removeBtn = document.createElement('button'); removeBtn.textContent = 'X'; removeBtn.className = 'remove-track-btn'; removeBtn.title = 'Remove'; | |
| removeBtn.onclick = () => this.uiManager.removeTrack(this.id); | |
| controlsEl.appendChild(removeBtn); | |
| const stepsEl = document.createElement('div'); stepsEl.className = 'steps-container'; this.steps = []; for (let i = 0; i < 64; i++) { const stepEl = document.createElement('div'); stepEl.className = 'step'; stepEl.dataset.index = i; this.steps.push(stepEl); stepsEl.appendChild(stepEl); } | |
| let lastPaintedStep = -1; | |
| const getStepIndexFromEvent = (e) => { | |
| const stepEl = e.target.closest('.step'); | |
| if (!stepEl || this.params.steps === 0) return -1; | |
| const rect = stepsEl.getBoundingClientRect(); const x = e.clientX - rect.left; const stepWidth = rect.width / this.params.steps; const index = Math.floor(x / stepWidth); return Math.max(0, Math.min(this.params.steps - 1, index)); | |
| }; | |
| stepsEl.onmousedown = (e) => { | |
| e.preventDefault(); | |
| if (e.button !== 0) return; | |
| const stepIndex = getStepIndexFromEvent(e); | |
| if (stepIndex === -1) return; | |
| if (e.shiftKey) { | |
| if (!this.params.stepParams[stepIndex]) { | |
| this.params.stepParams[stepIndex] = {}; | |
| } | |
| const step = this.params.stepParams[stepIndex]; | |
| step.accent = !step.accent; | |
| if (!step.accent && Object.keys(step).length === 1) { | |
| delete this.params.stepParams[stepIndex]; | |
| } else if (Object.keys(step).length === 0) { | |
| delete this.params.stepParams[stepIndex]; | |
| } | |
| this.updateUI(); | |
| return; | |
| } | |
| this.uiManager.startVerticalDrag(e, this, stepIndex); | |
| const isCurrentlyOn = this.shouldTrigger(stepIndex); | |
| this.uiManager.paintMode = !isCurrentlyOn; | |
| this.uiManager.isPaintingSteps = true; | |
| lastPaintedStep = stepIndex; | |
| if (isCurrentlyOn) { | |
| this.uiManager.potentialClickToggle = { track: this, stepIndex: stepIndex }; | |
| } else { | |
| this.setStepState(stepIndex, true); | |
| this.updateUI(); | |
| } | |
| }; | |
| stepsEl.onmousemove = (e) => { | |
| if (this.uiManager.isPaintingSteps) { | |
| const stepIndex = getStepIndexFromEvent(e); | |
| if (stepIndex !== -1 && stepIndex !== lastPaintedStep) { | |
| this.setStepState(stepIndex, this.uiManager.paintMode); | |
| lastPaintedStep = stepIndex; | |
| this.updateUI(); | |
| } | |
| } | |
| }; | |
| stepsEl.onmouseleave = () => { | |
| lastPaintedStep = -1; | |
| }; | |
| this.steps.forEach((stepEl) => { | |
| stepEl.oncontextmenu = (e) => { | |
| e.preventDefault(); | |
| const index = parseInt(stepEl.dataset.index, 10); | |
| this.uiManager.openStepModal(this, index); | |
| }; | |
| }); | |
| const infoEl = document.createElement('div'); infoEl.className = 'track-info'; | |
| const rootNoteDisplay = document.createElement('span'); const fxSendDisplay = document.createElement('span'); | |
| infoEl.appendChild(rootNoteDisplay); infoEl.appendChild(fxSendDisplay); | |
| trackEl.appendChild(controlsEl); trackEl.appendChild(stepsEl); trackEl.appendChild(infoEl); | |
| this.element = trackEl; | |
| return { setTriggered: (step) => { if (!this.element) return; const stepIndex = step % this.params.steps; const currentStepEl = this.steps[stepIndex]; if (currentStepEl) currentStepEl.classList.add('triggered'); }, update: () => this.updateUI(), element: trackEl, rootNoteDisplay, fxSendDisplay }; | |
| } | |
| randomize() { | |
| const possibleSteps = [8, 12, 16, 24, 32]; | |
| const newSteps = possibleSteps[randomInt(possibleSteps.length)]; | |
| this.params.steps = newSteps; | |
| this.params.pulses = randomInt(Math.floor(newSteps / 2)) + 1; | |
| this.params.offset = randomInt(newSteps); | |
| this.params.overrides = {}; | |
| this.params.stepParams = {}; | |
| this.sanitizeParams(); | |
| const melodicSynths = ['stab', 'dubchord', 'lead', 'eskibass', 'melodica', 'synthflute', 'shortpad', 'pad', 'funkstab', 'dubguitarriff', 'synthfunklick', 'vibraphone']; | |
| if(melodicSynths.includes(this.synth)){ | |
| this.mode = 'EUCLID'; | |
| } | |
| switch (this.synth) { | |
| case 'stab': case 'funkstab': case 'dubchord': case 'lead': case 'eskibass': | |
| case 'sfx_tuningtone': case 'sfx_manualsweep': case 'melodica': case 'synthflute': | |
| case 'dubguitarriff': case 'synthfunklick': case 'vibraphone': | |
| this.params.rootNote = 48 + randomInt(36); | |
| break; | |
| case 'subbass': | |
| this.params.rootNote = 24 + randomInt(24); | |
| break; | |
| case 'siren': case 'siren_aggro': | |
| this.params.rootNote = 72 + randomInt(24); | |
| break; | |
| default: | |
| break; | |
| } | |
| this.updateUI(); | |
| } | |
| clearStepParams() { | |
| this.params.stepParams = {}; | |
| this.updateUI(); | |
| } | |
| shuffleOctaves() { | |
| const octaveChoices = [-1.0, -0.5, 0, 0, 0, 0.5, 1.0]; | |
| for (let i = 0; i < this.params.steps; i++) { | |
| if (this.shouldTrigger(i)) { | |
| if (!this.params.stepParams[i]) { | |
| this.params.stepParams[i] = {}; | |
| } | |
| const randomOctave = octaveChoices[Math.floor(Math.random() * octaveChoices.length)]; | |
| this.params.stepParams[i].octave = randomOctave; | |
| if (randomOctave === 0 && Object.keys(this.params.stepParams[i]).length === 1) { | |
| delete this.params.stepParams[i]; | |
| } | |
| } | |
| } | |
| this.updateUI(); | |
| } | |
| setStepState(stepIndex, newState) { if (this.mode === 'GATE') { this.params.sequence[stepIndex] = newState; } else if (this.mode === 'EUCLID') { const euclidState = this.isEuclidStepOn(stepIndex); if (newState === euclidState) { delete this.params.overrides[stepIndex]; } else { this.params.overrides[stepIndex] = newState; } } } | |
| isEuclidStepOn(step) { const s = this.params; if (s.steps === 0 || s.pulses === 0) return false; const currentEuclidStep = (step + s.offset) % s.steps; return (Math.floor(currentEuclidStep * s.pulses / s.steps) !== Math.floor((currentEuclidStep - 1) * s.pulses / s.steps)); } | |
| shouldTrigger(step) { const s = this.params; const currentStepInLoop = step % s.steps; if (s.overrides && s.overrides[currentStepInLoop] !== undefined) { return s.overrides[currentStepInLoop]; } switch (this.mode) { case 'GATE': return s.sequence[currentStepInLoop]; case 'EUCLID': return this.isEuclidStepOn(currentStepInLoop); } return false; } | |
| updateUI() { | |
| this.element.classList.toggle('active', this.active); | |
| const name = this.synth.replace(/sfx_|siren_/g, '').replace('sub', '').replace('short','S').replace('synth','S').substring(0,4); | |
| const nameBtn = this.element.querySelector('.track-name-btn'); | |
| if (nameBtn) { nameBtn.textContent = `${name.toUpperCase()}`; } | |
| this.element.querySelector('.solo-btn').classList.toggle('toggled', this.solo); | |
| this.element.querySelector('.mute-btn').classList.toggle('toggled', this.mute); | |
| this.element.querySelector('.bypass-btn')?.classList.toggle('toggled', this.params.fxBypass); | |
| this.steps.forEach((s, i) => { | |
| s.style.display = i < this.params.steps ? 'block' : 'none'; | |
| const isOn = this.shouldTrigger(i); s.classList.toggle('on', isOn); | |
| s.style.backgroundColor = ''; | |
| const stepParams = this.params.stepParams[i] || {}; | |
| if (isOn && (stepParams.octave !== undefined)) { | |
| const baseLightness = 70; | |
| const lightnessRange = 30; | |
| const lightness = baseLightness + (stepParams.octave / 2) * lightnessRange; | |
| s.style.backgroundColor = `hsl(0, 0%, ${Math.max(30, Math.min(100, lightness))}%)`; | |
| } | |
| s.classList.toggle('accented', !!stepParams.accent); | |
| const hasModulations = Object.keys(stepParams).length > 0; | |
| if (hasModulations) { | |
| let tooltipText = []; | |
| if(stepParams.octave !== undefined && stepParams.octave !== 0) tooltipText.push(`Oct: ${stepParams.octave > 0 ? '+' : ''}${stepParams.octave.toFixed(1)}`); | |
| if(stepParams.accent) tooltipText.push(`Accent`); | |
| if(stepParams.velocity !== undefined && stepParams.velocity !== this.params.volume) tooltipText.push(`Vel: ${stepParams.velocity.toFixed(2)}`); | |
| if(stepParams.gate !== undefined && stepParams.gate !== 1.0) tooltipText.push(`Gate: ${stepParams.gate.toFixed(2)}x`); | |
| if(stepParams.probability !== undefined && stepParams.probability !== 1.0) tooltipText.push(`Prob: ${Math.round(stepParams.probability * 100)}%`); | |
| if(stepParams.ratchets !== undefined && stepParams.ratchets > 1) tooltipText.push(`Ratch: x${stepParams.ratchets}`); | |
| s.title = tooltipText.join(' | '); | |
| s.classList.toggle('modulated', tooltipText.length > 0 && !(tooltipText.length === 1 && stepParams.accent)); | |
| } else { | |
| s.title = ''; | |
| s.classList.remove('modulated'); | |
| } | |
| const hasOverride = this.mode === 'EUCLID' && this.params.overrides && this.params.overrides[i] !== undefined; | |
| s.classList.toggle('overridden', hasOverride); | |
| }); | |
| const melodicSynths = ['stab', 'funkstab', 'subbass', 'pad', 'shortpad', 'dubchord', 'drychord', 'reese', 'lead', 'eskibass', 'siren', 'siren_aggro', 'sfx_tuningtone', 'sfx_manualsweep', 'melodica', 'synthflute', 'midbass', 'dubbass', 'dubguitarriff', 'synthfunklick', 'vibraphone']; | |
| if (this.ui && this.ui.rootNoteDisplay) { const isMelodic = melodicSynths.includes(this.synth); this.ui.rootNoteDisplay.textContent = isMelodic ? `N:${this.params.rootNote}` : ''; } | |
| if (this.ui && this.ui.fxSendDisplay) { this.ui.fxSendDisplay.textContent = `D:${this.params.delaySend.toFixed(1)} R:${this.params.reverbSend.toFixed(1)}`; } | |
| } | |
| } | |
| class UIManager { | |
| constructor(engine) { | |
| this.engine = engine; this.trackIdCounter = 0; this.isPaintingSteps = false; this.paintMode = null; | |
| this.isDraggingStepVertically = false; | |
| this.dragTarget = { track: null, stepIndex: -1, startY: 0, startOctave: 0 }; | |
| this.potentialClickToggle = null; | |
| this.modalOriginalTrackState = null; | |
| this.idlePulses = []; | |
| this.lastPulseTime = 0; | |
| this.pulseInterval = 1500; | |
| this.dom = { | |
| playBtn: document.getElementById('play-button'), toggleAddTrackBtn: document.getElementById('toggle-add-track-button'), addTrackPanel: document.getElementById('add-track-panel'), bpmSlider: document.getElementById('bpm-slider'), | |
| bpmDisplay: document.getElementById('bpm-display'), volumeSlider: document.getElementById('volume-slider'), volumeDisplay: document.getElementById('volume-display'), swingSlider: document.getElementById('swing-slider'), | |
| swingDisplay: document.getElementById('swing-display'), sequencerGrid: document.getElementById('sequencer-grid'), phaseClock: document.getElementById('phase-clock'), modal: document.getElementById('track-editor-modal'), | |
| modalTitle: document.getElementById('modal-title'), modalBody: document.getElementById('modal-body'), modalPresetControls: document.getElementById('modal-preset-controls'), modalSave: document.getElementById('modal-save'), modalClose: document.getElementById('modal-close'), | |
| modalDelete: document.getElementById('modal-delete'), filterCutoff: document.getElementById('filter-cutoff'), delayAmount: document.getElementById('delay-amount'), delay2Amount: document.getElementById('delay2-amount'), | |
| reverbAmount: document.getElementById('reverb-amount'), saturationAmount: document.getElementById('saturation-amount'), saturationTone: document.getElementById('saturation-tone'), | |
| sidechainAmount: document.getElementById('sidechain-amount'), wipeBtn: document.getElementById('wipe-btn'), saveSessionBtn: document.getElementById('save-session-btn'), loadSessionBtn: document.getElementById('load-session-btn'), | |
| tapeAge: document.getElementById('tape-age'), wowFlutter: document.getElementById('wow-flutter'), | |
| modFxType: document.getElementById('mod-fx-type'), modFxAmount: document.getElementById('mod-fx-amount'), | |
| genreModal: document.getElementById('genre-preset-modal'), genreModalTitle: document.getElementById('genre-modal-title'), | |
| genreModalBody: document.getElementById('genre-modal-body'), genreModalClose: document.getElementById('genre-modal-close'), | |
| reductionSlider: document.getElementById('reduction-slider'), reductionDisplay: document.getElementById('reduction-display'), | |
| chordFreezeBtn: document.getElementById('chord-freeze-btn'), | |
| globalProbabilitySlider: document.getElementById('global-probability-slider'), globalProbabilityDisplay: document.getElementById('global-probability-display'), | |
| trackDriftSlider: document.getElementById('track-drift-slider'), trackDriftDisplay: document.getElementById('track-drift-display'), | |
| evolveRateSlider: document.getElementById('evolve-rate-slider'), evolveRateDisplay: document.getElementById('evolve-rate-display'), | |
| hissDensitySlider: document.getElementById('hiss-density-slider'), hissToneSlider: document.getElementById('hiss-tone-slider'), | |
| ghostNotesSlider: document.getElementById('ghost-notes-slider'), ghostNotesDisplay: document.getElementById('ghost-notes-display'), | |
| reverbPredelaySlider: document.getElementById('reverb-predelay-slider'), reverbPredelayDisplay: document.getElementById('reverb-predelay-display'), | |
| crackleSlider: document.getElementById('crackle-slider'), | |
| hissPatchBtn: document.getElementById('hiss-patch-btn'), | |
| tracksWrapper: document.getElementById('tracks-wrapper'), | |
| toggleTracksBtn: document.getElementById('toggle-tracks-btn'), | |
| masterDelaySend: document.getElementById('master-delay-send'), | |
| masterReverbSend: document.getElementById('master-reverb-send'), | |
| delayPitchDrift: document.getElementById('delay-pitch-drift'), | |
| delayFeedback: document.getElementById('delay-feedback'), | |
| delayLpf: document.getElementById('delay-lpf'), | |
| delayHpf: document.getElementById('delay-hpf'), | |
| randomAnyBtn: document.getElementById('random-any-btn'), | |
| }; | |
| this.ctx = this.dom.phaseClock.getContext('2d'); | |
| this.resizeCanvas(); | |
| this.dom.tooltip = document.getElementById('custom-tooltip'); | |
| this.tooltipTimeout = null; | |
| this.initListeners(); | |
| this.loadUserPresetsUI(); | |
| } | |
| initListeners() { | |
| window.addEventListener('mouseup', () => { | |
| if (this.potentialClickToggle) { | |
| const { track, stepIndex } = this.potentialClickToggle; | |
| track.setStepState(stepIndex, false); | |
| track.updateUI(); | |
| } | |
| this.isPaintingSteps = false; | |
| this.paintMode = null; | |
| this.potentialClickToggle = null; | |
| this.stopVerticalDrag(); | |
| }); | |
| window.addEventListener('mousemove', (e) => { | |
| if (this.potentialClickToggle) { | |
| this.potentialClickToggle = null; | |
| } | |
| if (this.dragTarget.track && !this.isDraggingStepVertically) { | |
| const deltaY = Math.abs(e.clientY - this.dragTarget.startY); | |
| const DRAG_THRESHOLD = 5; | |
| if (deltaY > DRAG_THRESHOLD) { | |
| this.isDraggingStepVertically = true; | |
| this.isPaintingSteps = false; | |
| } | |
| } | |
| if (this.isDraggingStepVertically) { | |
| this.handleVerticalDrag(e); | |
| } | |
| }); | |
| this.dom.playBtn.onclick = async () => { const isPlaying = await this.engine.togglePlay(); this.dom.playBtn.classList.toggle('active', isPlaying); this.dom.playBtn.textContent = isPlaying ? 'STOP' : 'PLAY'; }; | |
| this.dom.toggleAddTrackBtn.onclick = () => { this.dom.addTrackPanel.classList.toggle('open'); this.dom.toggleAddTrackBtn.classList.toggle('toggled'); }; | |
| this.dom.saveSessionBtn.onclick = () => this.saveFullSession(); | |
| this.dom.loadSessionBtn.onclick = () => this.loadFullSession(); | |
| this.dom.addTrackPanel.querySelectorAll('button[data-preset]').forEach(button => { | |
| const instrumentType = button.dataset.preset; | |
| const genreType = button.dataset.genre || 'default'; | |
| button.onclick = () => this.addTrackFromPreset(instrumentType, genreType); | |
| button.oncontextmenu = (e) => { e.preventDefault(); this.openGenreModal(instrumentType); }; | |
| }); | |
| this.dom.randomAnyBtn.onclick = () => { | |
| const allPresets = Object.keys(PRESETS); | |
| const randomPresetKey = allPresets[randomInt(allPresets.length)]; | |
| const newTrack = this.addTrackFromPreset(randomPresetKey); | |
| if (newTrack) { | |
| newTrack.randomize(); | |
| } | |
| }; | |
| this.dom.genreModalClose.onclick = () => this.dom.genreModal.style.display = 'none'; | |
| this.dom.bpmSlider.oninput = () => { this.engine.bpm = this.dom.bpmSlider.value; this.dom.bpmDisplay.textContent = this.engine.bpm; this.engine.setDelayAmount(this.dom.delayAmount.value); this.engine.setDelay2Amount(this.dom.delay2Amount.value); }; | |
| this.dom.volumeSlider.oninput = () => { if(this.engine.masterGain) this.engine.masterGain.gain.value = this.dom.volumeSlider.value; this.dom.volumeDisplay.textContent = parseFloat(this.dom.volumeSlider.value).toFixed(2); }; | |
| this.dom.swingSlider.oninput = () => { this.engine.swing = parseFloat(this.dom.swingSlider.value); this.dom.swingDisplay.textContent = `${Math.round(this.engine.swing * 100)}%`; }; | |
| this.dom.filterCutoff.oninput = () => this.engine.setFilterCutoff(this.dom.filterCutoff.value); | |
| this.dom.delayAmount.oninput = () => this.engine.setDelayAmount(this.dom.delayAmount.value); this.dom.delay2Amount.oninput = () => this.engine.setDelay2Amount(this.dom.delay2Amount.value); | |
| this.dom.reverbAmount.oninput = () => this.engine.setReverbAmount(this.dom.reverbAmount.value); | |
| this.dom.saturationAmount.oninput = () => this.engine.setSaturation(this.dom.saturationAmount.value); | |
| this.dom.saturationTone.oninput = () => this.engine.setSaturationTone(this.dom.saturationTone.value); | |
| this.dom.tapeAge.oninput = () => this.engine.setTapeAge(this.dom.tapeAge.value); | |
| this.dom.wowFlutter.oninput = () => this.engine.setWowFlutter(this.dom.wowFlutter.value); | |
| this.dom.modFxAmount.oninput = () => this.engine.setModFx(this.dom.modFxAmount.value); | |
| this.dom.modFxType.onclick = () => { const currentType = this.dom.modFxType.textContent; const newType = currentType === 'PHASER' ? 'FLANGER' : 'PHASER'; this.dom.modFxType.textContent = newType; this.engine.setModFxType(newType.toLowerCase()); }; | |
| this.dom.sidechainAmount.oninput = () => this.engine.setSidechain(this.dom.sidechainAmount.value); | |
| this.dom.wipeBtn.onclick = () => this.wipeSession(); | |
| window.onresize = () => this.resizeCanvas(); | |
| this.dom.reductionSlider.oninput = () => { const val = parseFloat(this.dom.reductionSlider.value); this.engine.setReduction(val); this.dom.reductionDisplay.textContent = `${Math.round(val*100)}%`; }; | |
| this.dom.chordFreezeBtn.onclick = () => { const currentMode = this.engine.toggleChordFreeze(); this.dom.chordFreezeBtn.textContent = `CHORD STASIS [${currentMode}]`; this.dom.chordFreezeBtn.classList.toggle('toggled', currentMode !== 'OFF'); }; | |
| this.dom.globalProbabilitySlider.oninput = () => { const val = parseFloat(this.dom.globalProbabilitySlider.value); this.engine.setGlobalProbability(val); this.dom.globalProbabilityDisplay.textContent = `${Math.round(val*100)}%`;}; | |
| this.dom.trackDriftSlider.oninput = () => { const val = parseFloat(this.dom.trackDriftSlider.value); this.engine.setTrackDrift(val); this.dom.trackDriftDisplay.textContent = `${(val * 1000).toFixed(1)}ms`;}; | |
| this.dom.evolveRateSlider.oninput = () => { const val = parseFloat(this.dom.evolveRateSlider.value); this.engine.setEvolveRate(val); this.dom.evolveRateDisplay.textContent = `${val.toFixed(2)}Hz`;}; | |
| this.dom.hissDensitySlider.oninput = () => this.engine.setHissDensity(parseFloat(this.dom.hissDensitySlider.value)); | |
| this.dom.hissToneSlider.oninput = () => this.engine.setHissTone(parseFloat(this.dom.hissToneSlider.value)); | |
| this.dom.ghostNotesSlider.oninput = () => { const val = parseFloat(this.dom.ghostNotesSlider.value); this.engine.setGhostNoteProbability(val); this.dom.ghostNotesDisplay.textContent = `${Math.round(val*100)}%`;}; | |
| this.dom.reverbPredelaySlider.oninput = () => { const val = parseFloat(this.dom.reverbPredelaySlider.value); this.engine.setReverbPreDelay(val); this.dom.reverbPredelayDisplay.textContent = `${Math.round(val * 1000)}ms`;}; | |
| this.dom.crackleSlider.oninput = () => this.engine.setCrackleAmount(parseFloat(this.dom.crackleSlider.value)); | |
| this.dom.hissPatchBtn.onclick = () => { const isPatched = this.dom.hissPatchBtn.classList.toggle('toggled'); this.engine.patchHissToFx(isPatched); this.dom.hissPatchBtn.textContent = isPatched ? 'PATCHED TO BUS' : 'PATCH TO FX BUS'; }; | |
| this.dom.masterDelaySend.oninput = () => this.engine.setMasterDelaySend(this.dom.masterDelaySend.value); | |
| this.dom.masterReverbSend.oninput = () => this.engine.setMasterReverbSend(this.dom.masterReverbSend.value); | |
| this.dom.delayPitchDrift.oninput = () => this.engine.setDelayPitchDrift(this.dom.delayPitchDrift.value); | |
| this.dom.delayFeedback.oninput = () => this.engine.setDelayFeedback(this.dom.delayFeedback.value); | |
| this.dom.delayLpf.oninput = () => this.engine.setDelayLpf(this.dom.delayLpf.value); | |
| this.dom.delayHpf.oninput = () => this.engine.setDelayHpf(this.dom.delayHpf.value); | |
| this.dom.toggleTracksBtn.onclick = () => { const isExpanded = this.dom.tracksWrapper.classList.toggle('expanded'); this.dom.toggleTracksBtn.textContent = isExpanded ? 'SHOW LESS' : 'SHOW MORE'; }; | |
| this.dom.routeKicksToFx = document.getElementById('route-kicks-to-fx'); | |
| this.dom.routeBassToFx = document.getElementById('route-bass-to-fx'); | |
| this.dom.dubSendModeBtn = document.getElementById('dub-send-mode-btn'); | |
| this.dom.routeKicksToFx.onchange = () => { | |
| this.engine.routeKicksToFx = this.dom.routeKicksToFx.checked; | |
| }; | |
| this.dom.routeBassToFx.onchange = () => { | |
| this.engine.routeBassToFx = this.dom.routeBassToFx.checked; | |
| }; | |
| this.dom.dubSendModeBtn.onclick = () => { | |
| const isIntegrated = this.engine.dubSendMode === 'integrated'; | |
| this.engine.dubSendMode = isIntegrated ? 'parallel' : 'integrated'; | |
| this.dom.dubSendModeBtn.textContent = `MODE: ${this.engine.dubSendMode.toUpperCase()}`; | |
| this.dom.dubSendModeBtn.classList.toggle('toggled', !isIntegrated); | |
| }; | |
| document.querySelectorAll('.control-panel.collapsible h3').forEach(header => { | |
| header.addEventListener('click', (e) => { | |
| if (e.target.closest('.panel-header-controls')) return; | |
| header.parentElement.classList.toggle('collapsed'); | |
| }); | |
| }); | |
| document.querySelectorAll('.control-panel').forEach((panel) => { | |
| const header = panel.querySelector('h3'); | |
| if (panel.id === '' && panel.querySelector('h3')) { | |
| console.warn('Collapsible panel found without an ID. Auto-randomization will not work for it.', panel); | |
| } | |
| if (header) { | |
| if (panel.classList.contains('collapsible')) { | |
| const panelId = panel.id; | |
| if (!panelId) return; | |
| const controlsContainer = document.createElement('div'); | |
| controlsContainer.className = 'panel-header-controls'; | |
| const walkBtn = document.createElement('button'); | |
| walkBtn.textContent = 'WALK'; | |
| walkBtn.className = 'randomize-panel-btn walk-panel-btn'; | |
| walkBtn.title = 'Slightly randomize this section'; | |
| walkBtn.onclick = (e) => { | |
| e.stopPropagation(); | |
| this.walkPanel(panel); | |
| this.flashRandomizeButton(panelId, walkBtn); | |
| }; | |
| const walkInput = document.createElement('input'); | |
| walkInput.type = 'number'; | |
| walkInput.className = 'randomize-interval-input'; | |
| walkInput.min = '0'; | |
| walkInput.value = '0'; | |
| walkInput.title = 'Auto-walk interval (in steps).\n0 is off.'; | |
| walkInput.onclick = (e) => e.stopPropagation(); | |
| walkInput.onchange = (e) => { | |
| this.engine.setPanelAutomation(panelId, 'walk', e.target.value); | |
| }; | |
| const randBtn = document.createElement('button'); | |
| randBtn.textContent = 'RND'; | |
| randBtn.className = 'randomize-panel-btn'; | |
| randBtn.title = 'Completely randomize this section'; | |
| randBtn.onclick = (e) => { | |
| e.stopPropagation(); | |
| this.randomizePanel(panel); | |
| this.flashRandomizeButton(panelId, randBtn); | |
| }; | |
| const randInput = document.createElement('input'); | |
| randInput.type = 'number'; | |
| randInput.className = 'randomize-interval-input'; | |
| randInput.min = '0'; | |
| randInput.value = '0'; | |
| randInput.title = 'Auto-randomize interval (in steps).\n0 is off.'; | |
| randInput.onclick = (e) => e.stopPropagation(); | |
| randInput.onchange = (e) => { | |
| this.engine.setPanelAutomation(panelId, 'rnd', e.target.value); | |
| }; | |
| controlsContainer.appendChild(walkBtn); | |
| controlsContainer.appendChild(walkInput); | |
| controlsContainer.appendChild(randBtn); | |
| controlsContainer.appendChild(randInput); | |
| header.appendChild(controlsContainer); | |
| } | |
| } else { | |
| const mainRandBtn = panel.querySelector('.randomize-panel-btn:not(.walk-panel-btn)'); | |
| if(mainRandBtn) mainRandBtn.onclick = (e) => { e.stopPropagation(); this.randomizePanel(panel); }; | |
| const mainWalkBtn = panel.querySelector('.walk-panel-btn'); | |
| if(mainWalkBtn) mainWalkBtn.onclick = (e) => { e.stopPropagation(); this.walkPanel(panel); }; | |
| } | |
| }); | |
| this.updateTrackVisibilityToggle(); | |
| const appContainer = document.getElementById('app-container'); | |
| let currentTooltipTarget = null; | |
| appContainer.addEventListener('mouseover', e => { | |
| const target = this.getTooltipTarget(e.target); | |
| if (target && !this.isDraggingStepVertically) { | |
| currentTooltipTarget = target; | |
| clearTimeout(this.tooltipTimeout); | |
| this.tooltipTimeout = setTimeout(() => { | |
| this.showTooltip(target, e); | |
| }, 250); | |
| } | |
| }); | |
| appContainer.addEventListener('mouseout', e => { | |
| const target = this.getTooltipTarget(e.target); | |
| if (target) { | |
| clearTimeout(this.tooltipTimeout); | |
| this.hideTooltip(); | |
| currentTooltipTarget = null; | |
| } | |
| }); | |
| appContainer.addEventListener('mousemove', e => { | |
| if (currentTooltipTarget && !this.isDraggingStepVertically) { | |
| this.updateTooltipPosition(e); | |
| } | |
| }); | |
| } | |
| walkPanelById(panelId) { | |
| const panel = document.getElementById(panelId); | |
| if (panel) { | |
| this.walkPanel(panel); | |
| this.flashRandomizeButton(panelId, panel.querySelector('.walk-panel-btn')); | |
| } | |
| } | |
| randomizePanelById(panelId) { | |
| const panel = document.getElementById(panelId); | |
| if (panel) { | |
| this.randomizePanel(panel); | |
| this.flashRandomizeButton(panelId, panel.querySelector('.randomize-panel-btn:not(.walk-panel-btn)')); | |
| } | |
| } | |
| flashRandomizeButton(panelId, btn) { | |
| if (!btn) return; | |
| btn.classList.add('flashing'); | |
| setTimeout(() => { | |
| btn.classList.remove('flashing'); | |
| }, 150); | |
| } | |
| startVerticalDrag(event, track, stepIndex) { | |
| const currentOctave = track.params.stepParams[stepIndex]?.octave ?? 0; | |
| this.dragTarget = { | |
| track: track, | |
| stepIndex: stepIndex, | |
| startY: event.clientY, | |
| startOctave: currentOctave, | |
| }; | |
| } | |
| handleVerticalDrag(event) { | |
| if (!this.isDraggingStepVertically || !this.dragTarget.track) return; | |
| const { track, stepIndex, startY, startOctave } = this.dragTarget; | |
| const deltaY = startY - event.clientY; | |
| const sensitivity = 40; | |
| let newOctave = startOctave + (deltaY / sensitivity); | |
| newOctave = Math.max(-2.0, Math.min(2.0, newOctave)); | |
| const finalOctave = Math.round(newOctave * 10) / 10; | |
| if (!track.params.stepParams[stepIndex]) { | |
| track.params.stepParams[stepIndex] = {}; | |
| } | |
| track.params.stepParams[stepIndex].octave = finalOctave; | |
| if (finalOctave === 0 && Object.keys(track.params.stepParams[stepIndex]).length === 1) { | |
| delete track.params.stepParams[stepIndex]; | |
| } | |
| track.updateUI(); | |
| const tooltipText = `Octave: ${finalOctave > 0 ? '+' : ''}${finalOctave.toFixed(1)}`; | |
| this.dom.tooltip.innerHTML = tooltipText; | |
| this.dom.tooltip.classList.remove('hidden'); | |
| this.updateTooltipPosition(event); | |
| } | |
| stopVerticalDrag() { | |
| if (this.isDraggingStepVertically) { | |
| this.hideTooltip(); | |
| } | |
| this.isDraggingStepVertically = false; | |
| this.dragTarget = { track: null, stepIndex: -1, startY: 0, startOctave: 0 }; | |
| } | |
| getTooltipTarget(element) { return element.closest('.track-info, .step[title], .track-controls button, .control-panel label, .control-panel h3, .randomize-interval-input, .walk-panel-btn'); } | |
| showTooltip(target, event) { | |
| const text = this.getTooltipText(target); | |
| if (!text) { this.hideTooltip(); return; } | |
| this.dom.tooltip.innerHTML = text; | |
| this.dom.tooltip.classList.remove('hidden'); | |
| this.updateTooltipPosition(event); | |
| } | |
| hideTooltip() { this.dom.tooltip.classList.add('hidden'); } | |
| updateTooltipPosition(event) { | |
| let x = event.clientX; let y = event.clientY - 10; | |
| const tooltipRect = this.dom.tooltip.getBoundingClientRect(); | |
| if (x + tooltipRect.width > window.innerWidth) { x = window.innerWidth - tooltipRect.width - 10; } | |
| if (y - tooltipRect.height < 0) { y = event.clientY + 25; } else { y = y - tooltipRect.height; } | |
| this.dom.tooltip.style.left = `${x}px`; this.dom.tooltip.style.top = `${y}px`; | |
| } | |
| getTooltipText(target) { | |
| if (target.matches('.track-info')) { const trackEl = target.closest('.track'); const trackId = parseInt(trackEl.dataset.id, 10); const track = this.engine.tracks.find(t => t.id === trackId); if (!track) return null; let text = `Track ${track.id}: ${track.synth.toUpperCase()}\n\n`; const noteNames = ["C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B"]; const isMelodic = ['stab', 'funkstab', 'subbass', 'pad', 'dubchord', 'lead', 'eskibass', 'shortpad', 'midbass', 'dubbass', 'dubguitarriff', 'synthfunklick', 'vibraphone'].includes(track.synth); if (isMelodic) { const note = noteNames[track.params.rootNote % 12]; const octave = Math.floor(track.params.rootNote / 12) - 1; text += `Base Note: ${track.params.rootNote} (${note}${octave})\n`; } text += `Delay Send: ${(track.params.delaySend * 100).toFixed(0)}%\n`; text += `Reverb Send: ${(track.params.reverbSend * 100).toFixed(0)}%`; return text; } | |
| if (target.matches('.step') && target.title) { let text = target.title.replace(/ \| /g, '\n'); if (target.classList.contains('overridden')) { text += '\n(Manual Override)'; } return text; } | |
| if ((target.matches('.track-controls button') || target.matches('.randomize-interval-input') || target.matches('.walk-panel-btn')) && target.title) { return target.title; } | |
| if (target.matches('label') && target.htmlFor === 'swing-slider') { return 'Swing / Shuffle\n\nAdds a small delay to every even-numbered step (2, 4, 6, etc.) to create a groovier, less robotic rhythm.'; } | |
| if (target.matches('label') && target.htmlFor === 'track-drift-slider') { return 'Track Drift\n\nIntroduces tiny, random timing variations to each track trigger, simulating the slight imprecision of analog hardware or a human player.'; } | |
| return null; | |
| } | |
| walkPanel(panel) { | |
| const sliders = panel.querySelectorAll('input[type="range"]'); | |
| sliders.forEach(slider => { | |
| const min = parseFloat(slider.min); | |
| const max = parseFloat(slider.max); | |
| const current = parseFloat(slider.value); | |
| const range = max - min; | |
| const stepSize = range * (Math.random() * 0.1 + 0.05); | |
| const direction = Math.random() < 0.5 ? -1 : 1; | |
| let newValue = current + (stepSize * direction); | |
| newValue = Math.max(min, Math.min(max, newValue)); | |
| slider.value = newValue; | |
| slider.dispatchEvent(new Event('input', { bubbles: true })); | |
| }); | |
| } | |
| randomizePanel(panel) { | |
| const sliders = panel.querySelectorAll('input[type="range"]'); | |
| sliders.forEach(slider => { | |
| const min = parseFloat(slider.min); const max = parseFloat(slider.max); let randomValue; | |
| if (slider.id.includes('cutoff') || slider.id.includes('tone') || slider.id.includes('age') || slider.id.includes('lpf') || slider.id.includes('hpf')) { | |
| const logMin = Math.log(min); const logMax = Math.log(max); | |
| randomValue = Math.exp(logMin + Math.random() * (logMax - logMin)); | |
| } else { | |
| if (['wow-flutter', 'track-drift-slider', 'evolve-rate-slider'].includes(slider.id)) { randomValue = min + (Math.random() * Math.random()) * (max - min); } else { randomValue = min + Math.random() * (max - min); } | |
| } | |
| slider.value = randomValue; | |
| slider.dispatchEvent(new Event('input', { bubbles: true })); | |
| }); | |
| const hissPatchBtn = panel.querySelector('#hiss-patch-btn'); | |
| if (hissPatchBtn && Math.random() > 0.6) { hissPatchBtn.click(); } | |
| const modFxTypeBtn = panel.querySelector('#mod-fx-type'); | |
| if (modFxTypeBtn && Math.random() > 0.5) { modFxTypeBtn.click(); } | |
| const chordFreezeBtn = panel.querySelector('#chord-freeze-btn'); | |
| if (chordFreezeBtn) { | |
| const modes = ['OFF', 'OFF', 'DRONE', 'MEMORY', 'LATCH']; | |
| const targetMode = modes[Math.floor(Math.random() * modes.length)]; | |
| let currentMode = this.engine.chordStasisMode; | |
| let safetyCounter = 0; | |
| while (currentMode !== targetMode && safetyCounter < 5) { | |
| chordFreezeBtn.click(); | |
| currentMode = this.engine.chordStasisMode; | |
| safetyCounter++; | |
| } | |
| } | |
| } | |
| updateTrackVisibilityToggle() { | |
| const trackCount = this.engine.tracks.length; | |
| if (trackCount > 3) { this.dom.toggleTracksBtn.style.display = 'block'; } else { this.dom.toggleTracksBtn.style.display = 'none'; this.dom.tracksWrapper.classList.remove('expanded'); this.dom.toggleTracksBtn.textContent = 'SHOW MORE'; } | |
| } | |
| wipeSession() { | |
| const tracksToRemove = [...this.engine.tracks]; | |
| tracksToRemove.forEach(track => this.removeTrack(track.id)); | |
| this.engine.bpm = 120; this.dom.bpmSlider.value = 120; this.dom.bpmDisplay.textContent = '120'; | |
| this.engine.swing = 0.1; this.dom.swingSlider.value = 0.1; this.dom.swingDisplay.textContent = '10%'; | |
| this.addTrackFromPreset('kick', 'dubtechno'); | |
| const chordTrack = this.addTrackFromPreset('dubchord'); | |
| if (chordTrack) { | |
| chordTrack.randomize(); | |
| if (Math.random() < 0.5) { | |
| chordTrack.shuffleOctaves(); | |
| } | |
| } | |
| const bassTypes = ['subbass', 'midbass', 'dubbass', 'eskibass']; | |
| const randomBassType = bassTypes[Math.floor(Math.random() * bassTypes.length)]; | |
| const bassTrack = this.addTrackFromPreset(randomBassType); | |
| if (bassTrack) { | |
| bassTrack.randomize(); | |
| if (bassTrack.synth !== 'eskibass') { | |
| bassTrack.params.fxBypass = true; | |
| } | |
| bassTrack.updateUI(); | |
| } | |
| const stabTypes = ['funkStab', 'glassystab', 'stab', 'shortpad', 'lead']; | |
| const randomStabType = stabTypes[Math.floor(Math.random() * stabTypes.length)]; | |
| const stabTrack = this.addTrackFromPreset(randomStabType); | |
| if (stabTrack) { | |
| stabTrack.randomize(); | |
| } | |
| const allPresets = Object.keys(PRESETS); | |
| const randomPresetKey = allPresets[randomInt(allPresets.length)]; | |
| const randomTrack = this.addTrackFromPreset(randomPresetKey); | |
| if (randomTrack) { | |
| randomTrack.randomize(); | |
| } | |
| this.dom.tracksWrapper.classList.add('expanded'); | |
| this.dom.toggleTracksBtn.textContent = 'SHOW LESS'; | |
| console.log("SESSION WIPED. Reloaded with randomized tracks."); | |
| } | |
| addTrack() { if(this.engine.tracks.length >= 64) return null; const track = new Track(this.trackIdCounter++, this.engine, this); this.engine.tracks.push(track); this.dom.sequencerGrid.appendChild(track.element); this.updateTrackVisibilityToggle(); return track; } | |
| addTrackFromPreset(preset, genre = 'default') { | |
| const track = this.addTrack(); | |
| if (!track) return null; | |
| let config = PRESETS[preset]?.[genre] || PRESETS[preset]?.['default']; | |
| if (!config) { console.error(`Preset not found: ${preset}/${genre}. Adding default.`); config = PRESETS.kick.default; } | |
| track.synth = config.synth; | |
| track.mode = config.mode || 'GATE'; | |
| track.params = { ...track.params, ...track.getDefaultSynthParams(config.synth) }; | |
| Object.assign(track.params, config.params); | |
| track.sanitizeParams(); | |
| if (config.params.bpm) { this.engine.bpm = config.params.bpm; this.dom.bpmSlider.value = config.params.bpm; this.dom.bpmDisplay.textContent = config.params.bpm; } | |
| track.updateUI(); return track; | |
| } | |
| removeTrack(id) { const trackIndex = this.engine.tracks.findIndex(t => t.id === id); if (trackIndex > -1) { this.engine.tracks[trackIndex].element.remove(); this.engine.tracks.splice(trackIndex, 1); this.updateTrackSoloMuteVisuals(); this.updateTrackVisibilityToggle(); } } | |
| duplicateTrack(trackIdToDuplicate) { | |
| const trackToDuplicate = this.engine.tracks.find(t => t.id === trackIdToDuplicate); | |
| if (!trackToDuplicate) return; | |
| const newTrack = this.addTrack(); | |
| if (!newTrack) return; | |
| newTrack.synth = trackToDuplicate.synth; | |
| newTrack.mode = trackToDuplicate.mode; | |
| newTrack.params = JSON.parse(JSON.stringify(trackToDuplicate.params)); | |
| newTrack.solo = false; | |
| newTrack.mute = false; | |
| newTrack.updateUI(); | |
| this.updateTrackSoloMuteVisuals(); | |
| } | |
| openGenreModal(instrumentType) { | |
| const genreOptions = PRESETS[instrumentType]; | |
| if (!genreOptions || Object.keys(genreOptions).length <= 1) { this.addTrackFromPreset(instrumentType, 'default'); return; } | |
| this.dom.genreModalTitle.textContent = `ADD ${instrumentType.toUpperCase()} TRACK`; | |
| this.dom.genreModalBody.innerHTML = ''; | |
| for (const genre in genreOptions) { if (genre === 'default') continue; const btn = document.createElement('button'); btn.textContent = genre.toUpperCase(); btn.onclick = () => { this.addTrackFromPreset(instrumentType, genre); this.dom.genreModal.style.display = 'none'; }; this.dom.genreModalBody.appendChild(btn); } | |
| this.dom.genreModal.style.display = 'flex'; | |
| } | |
| openModal(track) { | |
| this.modalOriginalTrackState = { | |
| active: track.active, | |
| synth: track.synth, | |
| mode: track.mode, | |
| params: JSON.parse(JSON.stringify(track.params)) | |
| }; | |
| this.dom.modal.style.display = 'flex'; | |
| this.dom.modalTitle.textContent = `EDIT TRACK ${track.id}`; | |
| this.dom.modalBody.innerHTML = this.getModalContent(track); | |
| this.dom.modalPresetControls.innerHTML = this.getModalPresetContent(); | |
| this.dom.modalSave.textContent = 'SAVE'; this.dom.modalDelete.style.display = 'inline-block'; | |
| const form = this.dom.modalBody.querySelector('form'); | |
| const liveUpdate = () => this.updateTrackFromModal(track, form); | |
| form.addEventListener('input', liveUpdate); | |
| form.addEventListener('change', liveUpdate); | |
| const savePresetBtn = this.dom.modalPresetControls.querySelector('#modal-save-preset'); | |
| const presetNameInput = this.dom.modalPresetControls.querySelector('#preset-name-input'); | |
| savePresetBtn.onclick = () => { | |
| const presetName = presetNameInput.value.trim(); | |
| if (!presetName) { alert("Please enter a name for the preset."); presetNameInput.focus(); return; } | |
| this.saveTrackPreset(track, presetName); | |
| presetNameInput.value = ''; | |
| }; | |
| const loadPresetBtn = this.dom.modalPresetControls.querySelector('#modal-load-preset'); | |
| loadPresetBtn.onclick = () => { this.openPresetLoadModal(track); }; | |
| this.dom.modalClose.onclick = () => { | |
| if (this.modalOriginalTrackState) { | |
| track.active = this.modalOriginalTrackState.active; | |
| track.synth = this.modalOriginalTrackState.synth; | |
| track.mode = this.modalOriginalTrackState.mode; | |
| track.params = this.modalOriginalTrackState.params; | |
| track.updateUI(); | |
| this.updateTrackSoloMuteVisuals(); | |
| } | |
| this.dom.modal.style.display = 'none'; | |
| this.modalOriginalTrackState = null; | |
| }; | |
| this.dom.modalSave.onclick = () => { this.dom.modal.style.display = 'none'; this.modalOriginalTrackState = null; }; | |
| this.dom.modalDelete.onclick = () => { this.removeTrack(track.id); this.dom.modal.style.display = 'none'; this.modalOriginalTrackState = null; }; | |
| } | |
| openStepModal(track, stepIndex) { | |
| this.dom.modal.style.display = 'flex'; | |
| this.dom.modalTitle.textContent = `EDIT STEP ${stepIndex + 1} (Track ${track.id})`; | |
| this.dom.modalBody.innerHTML = this.getStepModalContent(track, stepIndex); | |
| this.dom.modalPresetControls.innerHTML = ''; | |
| this.dom.modalSave.textContent = 'DONE'; this.dom.modalDelete.style.display = 'none'; | |
| const form = this.dom.modalBody.querySelector('form'); | |
| const handleModalInput = (event) => { | |
| const input = event.target; const display = input.parentElement.querySelector('.value-display'); | |
| if (display) { const value = parseFloat(input.value); switch (input.name) { case 'octave': display.textContent = value.toFixed(1); break; case 'velocity': display.textContent = value.toFixed(2); break; case 'gate': display.textContent = `${value.toFixed(2)}x`; break; case 'probability': display.textContent = `${Math.round(value * 100)}%`; break; case 'ratchets': display.textContent = `x${value}`; break;} } | |
| const newParams = { octave: parseFloat(form.querySelector('[name="octave"]').value), velocity: parseFloat(form.querySelector('[name="velocity"]').value), gate: parseFloat(form.querySelector('[name="gate"]').value), probability: parseFloat(form.querySelector('[name="probability"]').value), ratchets: parseInt(form.querySelector('[name="ratchets"]').value, 10) }; | |
| const isDefault = newParams.octave === 0 && newParams.velocity === track.params.volume && newParams.gate === 1.0 && newParams.probability === 1.0 && newParams.ratchets === 1; | |
| if (isDefault) delete track.params.stepParams[stepIndex]; else track.params.stepParams[stepIndex] = newParams; | |
| track.updateUI(); | |
| }; | |
| form.addEventListener('input', handleModalInput); | |
| form.querySelector('#reset-step-btn').onclick = () => { delete track.params.stepParams[stepIndex]; form.reset(); form.querySelectorAll('input').forEach(input => input.dispatchEvent(new Event('input'))); track.updateUI(); }; | |
| const closeModal = () => { this.dom.modal.style.display = 'none'; form.removeEventListener('input', handleModalInput); } | |
| this.dom.modalSave.onclick = closeModal; this.dom.modalClose.onclick = closeModal; | |
| } | |
| getStepModalContent(track, stepIndex) { | |
| const sp = track.params.stepParams[stepIndex] || {}; | |
| const p = (name, def) => sp[name] ?? def; | |
| return `<form> | |
| <div class="row"> | |
| <label>OCTAVE</label> | |
| <input type="range" name="octave" value="${p('octave', 0)}" min="-2" max="2" step="0.1"> | |
| <span class="value-display">${p('octave', 0).toFixed(1)}</span> | |
| </div> | |
| <div class="row"> | |
| <label>VELOCITY</label> | |
| <input type="range" name="velocity" value="${p('velocity', track.params.volume)}" min="0" max="1.5" step="0.01"> | |
| <span class="value-display">${p('velocity', track.params.volume).toFixed(2)}</span> | |
| </div> | |
| <div class="row"> | |
| <label>GATE</label> | |
| <input type="range" name="gate" value="${p('gate', 1.0)}" min="0.05" max="4" step="0.01"> | |
| <span class="value-display">${p('gate', 1.0).toFixed(2)}x</span> | |
| </div> | |
| <div class="row"> | |
| <label>PROBABILITY</label> | |
| <input type="range" name="probability" value="${p('probability', 1.0)}" min="0" max="1" step="0.01"> | |
| <span class="value-display">${Math.round(p('probability', 1.0) * 100)}%</span> | |
| </div> | |
| <div class="row"> | |
| <label>RATCHETS</label> | |
| <input type="range" name="ratchets" value="${p('ratchets', 1)}" min="1" max="8" step="1"> | |
| <span class="value-display">x${p('ratchets', 1)}</span> | |
| </div> | |
| <hr style="border: border-color: var(--border-color); margin: 15px 0;"> | |
| <button type="button" id="reset-step-btn" style="width: 100%; margin-bottom: 0;">RESET STEP TO DEFAULT</button> | |
| </form>`; | |
| } | |
| getModalContent(track) { | |
| let p = track.params; | |
| let paramsHtml = ''; | |
| if (track.mode === 'GATE') { | |
| paramsHtml = `<div class="row"><label>Steps</label><input type="number" name="steps" value="${p.steps}" min="1" max="64"></div>`; | |
| } else { | |
| paramsHtml = `<div class="row"><label>Steps</label><input type="number" name="steps" value="${p.steps}" min="1" max="64"></div><div class="row"><label>Pulses</label><input type="number" name="pulses" value="${p.pulses}" min="0" max="64"></div><div class="row"><label>Offset</label><input type="number" name="offset" value="${p.offset}" min="0" max="63"></div>`; | |
| } | |
| const synths = ['kick','kick909','snare','clap','rimshot', 'shaker', 'hat','hat909', 'cowbell', 'clave', 'funkmute','subbass','reese','eskibass','stab','funkstab','dubguitarriff', 'synthfunklick', 'vibraphone', 'lead', 'melodica', 'synthflute', 'dubchord','drychord','shortpad','pad','siren','siren_aggro','sfx_tuningtone','sfx_manualsweep','noise','sfx_sweep','sfx_riser','sfx_zap','sfx_clink']; | |
| let synthSelector = synths.map(s => `<option value="${s}" ${track.synth === s ? 'selected' : ''}>${s.replace(/_/g, ' ').toUpperCase()}</option>`).join(''); | |
| let synthControls = ''; | |
| if (['kick', 'kick909', 'hat', 'hat909', 'shaker', 'noise', 'sfx_zap', 'sfx_clink', 'cowbell', 'clave'].includes(track.synth)) { | |
| synthControls += `<hr><h3>SOUND SHAPING</h3><div class="row"><label>Decay</label><input type="range" name="decay" value="${p.decay}" min="0.1" max="4" step="0.01"></div>`; | |
| } | |
| if (['snare', 'rimshot', 'clap'].includes(track.synth)) { | |
| synthControls += `<hr><h3>SOUND SHAPING</h3><div class="row"><label>Decay</label><input type="range" name="decay" value="${p.decay}" min="0.1" max="4" step="0.01"></div><div class="row"><label>Tone</label><input type="range" name="tone" value="${p.tone}" min="500" max="8000" step="10"></div>`; | |
| } | |
| if (['subbass', 'lead', 'reese', 'eskibass', 'melodica', 'sfx_tuningtone', 'dubguitarriff'].includes(track.synth)) { | |
| synthControls += `<hr><h3>AMP ENVELOPE</h3> <div class="row"><label>Attack</label><input type="range" name="attack" value="${p.attack}" min="0.001" max="1" step="0.001"></div> <div class="row"><label>Decay</label><input type="range" name="decay" value="${p.decay}" min="0.01" max="2" step="0.01"></div> <div class="row"><label>Sustain</label><input type="range" name="sustain" value="${p.sustain}" min="0" max="1" step="0.01"></div> <div class="row"><label>Release</label><input type="range" name="release" value="${p.release}" min="0.01" max="4" step="0.01"></div>`; | |
| } | |
| if (['stab', 'pad', 'shortpad', 'dubchord', 'drychord', 'synthfunklick', 'vibraphone'].includes(track.synth)) { | |
| synthControls += `<hr><h3>AMP ENVELOPE</h3> <div class="row"><label>Attack</label><input type="range" name="attack" value="${p.attack}" min="0.001" max="2" step="0.001"></div> <div class="row"><label>Decay</label><input type="range" name="decay" value="${p.decay}" min="0.01" max="4" step="0.01"></div> <div class="row"><label>Sustain</label><input type="range" name="sustain" value="${p.sustain}" min="0" max="1" step="0.01"></div> <div class="row"><label>Release</label><input type="range" name="release" value="${p.release}" min="0.01" max="6" step="0.01"></div>`; | |
| if (['stab', 'shortpad', 'dubchord', 'drychord', 'synthfunklick'].includes(track.synth)) { | |
| synthControls += `<hr><h3>FILTER</h3> <div class="row"><label>Cutoff</label><input type="range" name="filterCutoff" value="${p.filterCutoff}" min="100" max="15000" step="1"></div> <div class="row"><label>Resonance</label><input type="range" name="filterQ" value="${p.filterQ}" min="0" max="20" step="0.1"></div>`; | |
| synthControls += `<hr><h3>FILTER ENVELOPE</h3> <div class="row"><label>Env Amount</label><input type="range" name="filterEnvAmount" value="${p.filterEnvAmount}" min="-8000" max="8000" step="10"></div> <div class="row"><label>Attack</label><input type="range" name="filterAttack" value="${p.filterAttack}" min="0.001" max="2" step="0.001"></div> <div class="row"><label>Decay</label><input type="range" name="filterDecay" value="${p.filterDecay}" min="0.01" max="4" step="0.01"></div> <div class="row"><label>Sustain</label><input type="range" name="filterSustain" value="${p.filterSustain}" min="0" max="1" step="0.01"></div> <div class="row"><label>Release</label><input type="range" name="filterRelease" value="${p.filterRelease}" min="0.01" max="6" step="0.01"></div>`; | |
| } | |
| } | |
| if (track.synth === 'synthfunklick') { | |
| synthControls += `<hr><h3>LICK CONTROLS</h3> <div class="row"><label>Pitch Sweep</label><input type="range" name="pitchSweepAmount" value="${p.pitchSweepAmount}" min="1" max="8" step="0.1"></div> <div class="row"><label>Sweep Time</label><input type="range" name="pitchSweepTime" value="${p.pitchSweepTime}" min="0.01" max="0.2" step="0.005"></div>`; | |
| } | |
| if (track.synth === 'synthflute') synthControls += `<hr><h3>SYNTH FLUTE</h3> <div class="row"><label>Attack</label><input type="range" name="attack" value="${p.attack}" min="0.001" max="1" step="0.001"></div> <div class="row"><label>Decay</label><input type="range" name="decay" value="${p.decay}" min="0.01" max="1" step="0.01"></div> <div class="row"><label>Sustain</label><input type="range" name="sustain" value="${p.sustain}" min="0" max="1" step="0.01"></div> <div class="row"><label>Release</label><input type="range" name="release" value="${p.release}" min="0.01" max="2" step="0.01"></div> <div class="row"><label>Vibrato Rate</label><input type="range" name="vibratoRate" value="${p.vibratoRate}" min="2" max="12" step="0.1"></div><div class="row"><label>Vibrato Depth</label><input type="range" name="vibratoDepth" value="${p.vibratoDepth}" min="0" max="20" step="0.1"></div> <div class="row"><label>Noise/Air</label><input type="range" name="noiseAmount" value="${p.noiseAmount}" min="0" max="0.5" step="0.01"></div> <div class="row"><label>Air Tone</label><input type="range" name="noiseTone" value="${p.noiseTone}" min="2000" max="10000" step="100"></div>`; | |
| if (['siren', 'siren_aggro', 'sfx_manualsweep'].includes(track.synth)) { | |
| synthControls += `<hr><h3>FADES</h3><div class="row"><label>Attack</label><input type="range" name="attack" value="${p.attack}" min="0.01" max="2" step="0.01"></div><div class="row"><label>Release</label><input type="range" name="release" value="${p.release}" min="0.01" max="2" step="0.01"></div>`; | |
| } | |
| if (track.synth === 'siren') synthControls += `<hr><h3>SIREN CONTROLS</h3><div class="row"><label>LFO Rate</label><input type="range" name="lfoRate" value="${p.lfoRate}" min="2" max="20" step="0.1"></div><div class="row"><label>LFO Depth</label><input type="range" name="lfoDepth" value="${p.lfoDepth}" min="0" max="1000" step="1"></div><div class="row"><label>Pitch Bend</label><input type="range" name="pitchBend" value="${p.pitchBend}" min="0" max="1" step="0.01"></div><div class="row"><label>Tone</label><input type="range" name="tone" value="${p.tone}" min="300" max="8000" step="1"></div>`; | |
| if (track.synth === 'siren_aggro') { const shapes = ['square', 'sawtooth']; let shapeOpts = shapes.map(s => `<option value="${s}" ${p.oscillatorType === s ? 'selected' : ''}>${s}</option>`).join(''); synthControls += `<hr><h3>AGGRO SIREN</h3><div class="row"><label>Osc Shape</label><select name="oscillatorType">${shapeOpts}</select></div><div class="row"><label>LFO Rate</label><input type="range" name="lfoRate" value="${p.lfoRate}" min="2" max="30" step="0.1"></div><div class="row"><label>LFO Depth</label><input type="range" name="lfoDepth" value="${p.lfoDepth}" min="50" max="2000" step="1"></div>`; } | |
| if (track.synth === 'sfx_manualsweep') synthControls += `<hr><h3>MANUAL SWEEP</h3><div class="row"><label>Sweep Time (s)</label><input type="range" name="sweepTime" value="${p.sweepTime}" min="0.1" max="4" step="0.05"></div><div class="row"><label>End Pitch</label><input type="range" name="endPitchMultiplier" value="${p.endPitchMultiplier}" min="0.01" max="2" step="0.01"></div>`; | |
| if (['lead', 'vibraphone'].includes(track.synth)) synthControls += `<hr><h3>VIBRATO</h3><div class="row"><label>Vibrato Rate</label><input type="range" name="vibratoRate" value="${p.vibratoRate}" min="2" max="12" step="0.1"></div><div class="row"><label>Vibrato Depth</label><input type="range" name="vibratoDepth" value="${p.vibratoDepth}" min="0" max="20" step="0.1"></div>`; | |
| if (['subbass', 'lead', 'reese'].includes(track.synth)) synthControls += `<hr><h3>FILTER</h3> <div class="row"><label>Cutoff</label><input type="range" name="filterCutoff" value="${p.filterCutoff}" min="100" max="10000" step="1"></div> <div class="row"><label>Resonance</label><input type="range" name="filterQ" value="${p.filterQ}" min="0" max="20" step="0.1"></div>`; | |
| if (track.synth === 'subbass') { const shapes = ['sine', 'square', 'sawtooth']; let shapeOpts = shapes.map(s => `<option value="${s}" ${p.lfoShape === s ? 'selected' : ''}>${s}</option>`).join(''); synthControls += `<hr><h3>LFO (TO FILTER)</h3> <div class="row"><label>Shape</label><select name="lfoShape">${shapeOpts}</select></div> <div class="row"><label>Rate (1/32)</label><input type="range" name="lfoRate" value="${p.lfoRate}" min="1" max="32" step="1"></div> <div class="row"><label>Depth</label><input type="range" name="lfoDepth" value="${p.lfoDepth}" min="0" max="8000" step="1"></div>`; } | |
| if (track.synth === 'reese' || track.synth === 'eskibass' || track.synth === 'melodica' ) { synthControls += `<hr><h3>OSCILLATOR</h3><div class="row"><label>Detune</label><input type="range" name="detune" value="${p.detune}" min="0" max="50" step="0.1"></div>`; } | |
| if (track.synth === 'eskibass') synthControls += `<div class="row"><label>Glide</label><input type="range" name="glide" value="${p.glide}" min="0" max="0.5" step="0.01"></div>`; | |
| let musicalControls = `<hr><h3>TUNING</h3><div class="row"><label>Base Pitch (MIDI)</label><input type="number" name="rootNote" value="${p.rootNote}" min="24" max="96"></div>`; | |
| if (['stab', 'funkstab', 'subbass', 'reese', 'pad', 'shortpad', 'dubchord', 'drychord', 'lead', 'melodica', 'synthflute', 'dubguitarriff', 'synthfunklick', 'vibraphone'].includes(track.synth)) { let scaleOptions = Object.keys(SCALES).map(s => `<option value="${s}" ${p.scale === s ? 'selected' : ''}>${s}</option>`).join(''); musicalControls += `<div class="row"><label>Scale</label><select name="scale">${scaleOptions}</select></div>`; } | |
| if (track.synth === 'dubchord') { const voicings = ['stacked', 'spread', 'drop2']; let voicingOptions = voicings.map(v => `<option value="${v}" ${p.voicing === v ? 'selected' : ''}>${v}</option>`).join(''); const inversions = [0, 1, 2]; let inversionOptions = inversions.map(i => `<option value="${i}" ${p.inversion == i ? 'selected' : ''}>${i}</option>`).join(''); musicalControls += `<div class="row"><label>Voicing</label><select name="voicing">${voicingOptions}</select></div>`; musicalControls += `<div class="row"><label>Inversion</label><select name="inversion">${inversionOptions}</select></div>`; } | |
| let mixControls = `<hr><h3>MIX & SEND</h3> | |
| <div class="row"> | |
| <label class="stretch">FX Bypass <small style="color: var(--disabled-text); font-size: 0.9em; display: block;">Bypass master FX chain.</small></label> | |
| <input type="checkbox" name="fxBypass" ${p.fxBypass ? 'checked' : ''}> | |
| </div> | |
| <div class="row"> | |
| <label class="stretch">Ghost Notes <small style="color: var(--disabled-text); font-size: 0.9em; display: block;">Allow sequencer to add low-velocity ghost notes.</small></label> | |
| <input type="checkbox" name="allowGhostNotes" ${p.allowGhostNotes ? 'checked' : ''}> | |
| </div> | |
| <div class="row"><label>Mod Send</label><input type="range" name="modSend" value="${p.modSend ?? 0.0}" min="0" max="1.5" step="0.01"></div> | |
| <div class="row"><label>Delay Send</label><input type="range" name="delaySend" value="${p.delaySend ?? 1.0}" min="0" max="1.5" step="0.01"></div> | |
| <div class="row"><label>Reverb Send</label><input type="range" name="reverbSend" value="${p.reverbSend ?? 1.0}" min="0" max="1.5" step="0.01"></div> | |
| <hr><h3>SWING</h3> | |
| <div class="row"> | |
| <label class="stretch">Use Global Swing</label> | |
| <input type="checkbox" name="useGlobalSwing" ${p.useGlobalSwing ? 'checked' : ''}> | |
| </div> | |
| <div class="row"> | |
| <label>Track Swing</label> | |
| <input type="range" name="swingAmount" value="${p.swingAmount}" min="0" max="0.8" step="0.01" ${p.useGlobalSwing ? 'disabled' : ''}> | |
| <span class="value-display">${Math.round(p.swingAmount*100)}%</span> | |
| </div> | |
| <hr><h3>FILTERING</h3> | |
| <div class="row"><label>Highpass Freq</label><input type="range" name="highpass" value="${p.highpass || 20}" min="20" max="10000" step="10"></div> | |
| <div class="row"><label>Lowpass Freq</label><input type="range" name="lowpass" value="${p.lowpass || 20000}" min="100" max="22000" step="100"></div>`; | |
| return `<form> | |
| <div class="row"> | |
| <label class="stretch">Active</label> | |
| <input type="checkbox" name="active" ${track.active ? 'checked' : ''}> | |
| </div> | |
| <hr> | |
| <div class="row"><label>Volume</label><input type="range" name="volume" value="${p.volume}" min="0" max="1.5" step="0.01"></div> | |
| <div class="row"><label>Sound</label><select name="synth">${synthSelector}</select></div> | |
| <div class="row"><label>Mode</label><select name="mode"><option value="GATE" ${track.mode === 'GATE' ? 'selected' : ''}>Gate</option><option value="EUCLID" ${track.mode === 'EUCLID' ? 'selected' : ''}>Euclid</option></select></div> | |
| ${paramsHtml}${mixControls}${musicalControls}${synthControls} | |
| </form>`; | |
| } | |
| getModalPresetContent() { return `<hr><h3>PRESET</h3><div class="row"><input type="text" id="preset-name-input" placeholder="New Preset" style="flex-basis: 0; flex-grow: 2;"><button type="button" id="modal-save-preset" style="flex-grow: 1;">SAVE PRESET</button></div><div class="row"><button type="button" id="modal-load-preset">LOAD PRESET</button></div>`; } | |
| updateTrackFromModal(track, form) { | |
| const newSynth = form.querySelector('[name="synth"]').value; | |
| if (newSynth !== track.synth) { | |
| const newSynthDefaults = track.getDefaultSynthParams(newSynth); | |
| Object.assign(track.params, newSynthDefaults); | |
| track.sanitizeParams(); | |
| this.dom.modalBody.innerHTML = this.getModalContent(track); | |
| const newForm = this.dom.modalBody.querySelector('form'); | |
| const liveUpdate = () => this.updateTrackFromModal(track, newForm); | |
| newForm.addEventListener('input', liveUpdate); | |
| newForm.addEventListener('change', liveUpdate); | |
| form = newForm; | |
| } | |
| track.synth = newSynth; | |
| form.querySelectorAll('input, select').forEach(input => { | |
| const name = input.name; | |
| if (!name) return; | |
| let value; | |
| switch (input.type) { | |
| case 'checkbox': value = input.checked; break; | |
| case 'range': case 'number': value = input.step && (input.step.includes('.') || parseFloat(input.step) < 1) ? parseFloat(input.value) : parseInt(input.value, 10); break; | |
| default: value = input.value; break; | |
| } | |
| if (name === 'active') { track.active = value; } | |
| else if (name === 'mode') { track.mode = value; } | |
| else { track.params[name] = value; } | |
| }); | |
| const useGlobalCheckbox = form.querySelector('[name="useGlobalSwing"]'); | |
| const swingSlider = form.querySelector('[name="swingAmount"]'); | |
| const swingDisplay = swingSlider ? swingSlider.nextElementSibling : null; | |
| if(useGlobalCheckbox && swingSlider){ | |
| track.params.useGlobalSwing = useGlobalCheckbox.checked; | |
| swingSlider.disabled = useGlobalCheckbox.checked; | |
| if(!useGlobalCheckbox.checked){ | |
| track.params.swingAmount = parseFloat(swingSlider.value); | |
| } | |
| if (swingDisplay) { | |
| swingDisplay.textContent = `${Math.round(swingSlider.value*100)}%`; | |
| } | |
| } | |
| track.sanitizeParams(); | |
| track.updateUI(); | |
| this.updateTrackSoloMuteVisuals(); | |
| } | |
| saveTrackPreset(track, name) { | |
| const trackState = { synth: track.synth, mode: track.mode, params: track.params }; | |
| const jsonString = JSON.stringify(trackState); | |
| const key = `user_preset_${name}`; | |
| try { | |
| localStorage.setItem(key, jsonString); | |
| alert(`Preset "${name}" saved!`); | |
| this.loadUserPresetsUI(); | |
| } catch (e) { | |
| alert("Could not save preset. Local storage might be full."); | |
| console.error("Error saving to localStorage:", e); | |
| } | |
| } | |
| loadTrackPreset(track, jsonString) { | |
| try { | |
| const loadedState = JSON.parse(jsonString); | |
| track.synth = loadedState.synth; | |
| track.mode = loadedState.mode || 'GATE'; | |
| const defaults = track.getDefaultSynthParams(track.synth); | |
| track.params = Object.assign({}, defaults, loadedState.params); | |
| track.sanitizeParams(); | |
| track.updateUI(); | |
| if (this.dom.modal.style.display === 'flex') { | |
| this.openModal(track); | |
| } | |
| } catch (e) { console.error("Error loading preset:", e); } | |
| } | |
| openPresetLoadModal(targetTrack) { | |
| const userPresets = this.getUserPresets(); | |
| if (userPresets.length === 0) { alert("No user presets found."); return; } | |
| this.dom.genreModalTitle.textContent = 'LOAD USER PRESET'; | |
| this.dom.genreModalBody.innerHTML = ''; | |
| userPresets.forEach(({name, jsonString}) => { | |
| const btn = document.createElement('button'); | |
| btn.textContent = name; | |
| btn.onclick = () => { | |
| this.loadTrackPreset(targetTrack, jsonString); | |
| this.dom.genreModal.style.display = 'none'; | |
| }; | |
| this.dom.genreModalBody.appendChild(btn); | |
| }); | |
| this.dom.genreModal.style.display = 'flex'; | |
| } | |
| getUserPresets() { | |
| const presets = []; | |
| for (let i = 0; i < localStorage.length; i++) { | |
| const key = localStorage.key(i); | |
| if (key.startsWith('user_preset_')) { | |
| presets.push({ | |
| name: key.replace('user_preset_', ''), | |
| jsonString: localStorage.getItem(key) | |
| }); | |
| } | |
| } | |
| return presets.sort((a,b) => a.name.localeCompare(b.name)); | |
| } | |
| loadUserPresetsUI() { | |
| const container = document.getElementById('user-presets-container'); | |
| if (!container) return; | |
| container.innerHTML = ''; | |
| const userPresets = this.getUserPresets(); | |
| if (userPresets.length > 0) { | |
| const header = document.createElement('div'); | |
| header.className = 'sub-header'; | |
| header.textContent = '--- CUSTOM PRESETS ---'; | |
| header.style.gridColumn = '1 / -1'; | |
| container.appendChild(header); | |
| userPresets.forEach(({name, jsonString}) => { | |
| const btn = document.createElement('button'); | |
| btn.textContent = name; | |
| btn.onclick = () => { | |
| const newTrack = this.addTrack(); | |
| if (newTrack) this.loadTrackPreset(newTrack, jsonString); | |
| }; | |
| container.appendChild(btn); | |
| }); | |
| } | |
| } | |
| saveFullSession() { | |
| const sessionState = { | |
| version: "1.1", | |
| globalParams: this.getGlobalParamsState(), | |
| tracks: this.engine.tracks.map(track => ({ | |
| synth: track.synth, | |
| mode: track.mode, | |
| active: track.active, | |
| solo: track.solo, | |
| mute: track.mute, | |
| params: track.params | |
| })) | |
| }; | |
| const jsonString = JSON.stringify(sessionState, null, 2); | |
| const blob = new Blob([jsonString], { type: 'application/json' }); | |
| const url = URL.createObjectURL(blob); | |
| const a = document.createElement('a'); | |
| a.href = url; | |
| a.download = `sequencer-session-${new Date().toISOString().slice(0, 10)}.json`; | |
| document.body.appendChild(a); | |
| a.click(); | |
| document.body.removeChild(a); | |
| URL.revokeObjectURL(url); | |
| } | |
| loadFullSession() { | |
| const input = document.createElement('input'); | |
| input.type = 'file'; | |
| input.accept = '.json,application/json'; | |
| input.onchange = e => { | |
| const file = e.target.files[0]; | |
| if (!file) return; | |
| const reader = new FileReader(); | |
| reader.onload = re => { | |
| try { | |
| const sessionState = JSON.parse(re.target.result); | |
| this.applyFullSessionState(sessionState); | |
| } catch (err) { | |
| console.error("Error parsing session file:", err); | |
| alert("Could not load session file. It may be corrupt."); | |
| } | |
| }; | |
| reader.readAsText(file); | |
| }; | |
| input.click(); | |
| } | |
| getGlobalParamsState() { | |
| const state = {}; | |
| const controlsToSave = { | |
| 'bpm-slider': 'value', 'volume-slider': 'value', 'swing-slider': 'value', | |
| 'saturation-amount': 'value', 'saturation-tone': 'value', 'tape-age': 'value', 'wow-flutter': 'value', | |
| 'mod-fx-amount': 'value', 'delay-amount': 'value', 'delay-pitch-drift': 'value', | |
| 'delay-lpf': 'value', 'delay-hpf': 'value', 'delay2-amount': 'value', 'reverb-amount': 'value', | |
| 'reverb-predelay-slider': 'value', 'sidechain-amount': 'value', 'filter-cutoff': 'value', | |
| 'master-delay-send': 'value', 'delay-feedback': 'value', 'master-reverb-send': 'value', | |
| 'hiss-density-slider': 'value', 'hiss-tone-slider': 'value', 'crackle-slider': 'value', | |
| 'reduction-slider': 'value', 'global-probability-slider': 'value', 'track-drift-slider': 'value', | |
| 'evolve-rate-slider': 'value', 'ghost-notes-slider': 'value', | |
| 'mod-fx-type': 'textContent', | |
| 'hiss-patch-btn': 'toggled', | |
| 'chord-freeze-btn': 'textContent', | |
| 'route-kicks-to-fx': 'checked', | |
| 'route-bass-to-fx': 'checked' | |
| }; | |
| for (const [id, prop] of Object.entries(controlsToSave)) { | |
| const el = this.dom[id] || document.getElementById(id); | |
| if (el) { | |
| if (prop === 'toggled') state[id] = el.classList.contains('toggled'); | |
| else state[id] = el[prop]; | |
| } | |
| } | |
| return state; | |
| } | |
| applyFullSessionState(state) { | |
| while (this.engine.tracks.length > 0) { | |
| this.removeTrack(this.engine.tracks[0].id); | |
| } | |
| if (state.globalParams) { | |
| for (const [id, value] of Object.entries(state.globalParams)) { | |
| const el = this.dom[id] || document.getElementById(id); | |
| if (!el) continue; | |
| if (id === 'mod-fx-type' || id === 'chord-freeze-btn') { | |
| let safety = 0; | |
| while(el.textContent !== value && safety < 5) { el.click(); safety++; } | |
| } else if (id === 'hiss-patch-btn') { | |
| if (el.classList.contains('toggled') !== value) el.click(); | |
| } else if (el.type === 'checkbox') { | |
| el.checked = value; | |
| } else { | |
| el.value = value; | |
| } | |
| el.dispatchEvent(new Event('input', { bubbles: true })); | |
| el.dispatchEvent(new Event('change', { bubbles: true })); | |
| } | |
| } | |
| if (state.tracks && Array.isArray(state.tracks)) { | |
| state.tracks.forEach(trackState => { | |
| const newTrack = this.addTrack(); | |
| if (newTrack) { | |
| newTrack.synth = trackState.synth; | |
| newTrack.mode = trackState.mode; | |
| newTrack.active = trackState.active ?? true; | |
| newTrack.solo = trackState.solo ?? false; | |
| newTrack.mute = trackState.mute ?? false; | |
| const defaults = newTrack.getDefaultSynthParams(newTrack.synth); | |
| newTrack.params = Object.assign({}, defaults, trackState.params); | |
| newTrack.sanitizeParams(); | |
| newTrack.updateUI(); | |
| } | |
| }); | |
| } | |
| this.updateTrackSoloMuteVisuals(); | |
| console.log("Session loaded successfully."); | |
| } | |
| updateCurrentStep(step) { this.engine.tracks.forEach(track => { track.steps.forEach(s => s.classList.remove('current')); if (track.active) { const stepIndex = step % track.params.steps; if(track.steps[stepIndex]) track.steps[stepIndex].classList.add('current'); } }); } | |
| updateTrackSoloMuteVisuals() { | |
| const anySolo = this.engine.tracks.some(t => t.solo); | |
| this.engine.tracks.forEach(track => { | |
| track.element.querySelector('.solo-btn').classList.toggle('toggled', track.solo); | |
| track.element.querySelector('.mute-btn').classList.toggle('toggled', track.mute); | |
| track.element.querySelector('.bypass-btn')?.classList.toggle('toggled', track.params.fxBypass); | |
| track.element.classList.toggle('muted', track.mute); | |
| track.element.classList.toggle('soloed', track.solo); | |
| track.element.classList.toggle('inactive-solo', anySolo && !track.solo); | |
| }); | |
| } | |
| resizeCanvas() { const container = this.dom.phaseClock.parentElement; const size = Math.min(container.clientWidth, container.clientHeight) * 0.9; this.dom.phaseClock.width = size; this.dom.phaseClock.height = size; this.radius = size / 2; } | |
| triggerPulse(track, isBypass) { | |
| if (!track) return; | |
| track.pulse = 1.0; | |
| const snapshot = { | |
| rootNote: track.params.rootNote, | |
| steps: track.params.steps, | |
| delaySend: track.params.delaySend, | |
| reverbSend: track.params.reverbSend, | |
| volume: track.params.volume | |
| }; | |
| this.idlePulses.push({ | |
| startTime: Date.now(), | |
| duration: isBypass ? 3300 : 1800, | |
| maxRadius: this.radius * 0.85 * (0.8 + snapshot.volume * 0.4), | |
| isBypass: isBypass, | |
| trackId: track.id, | |
| snapshot: isBypass ? snapshot : null | |
| }); | |
| } | |
| drawSacredShape(ctx, centerX, centerY, radius, opacity, rotation, snapshot, reduction) { | |
| const reverbGlow = (snapshot?.reverbSend ?? 0) * 0.02 * (1 - reduction); | |
| ctx.shadowColor = `rgba(221, 221, 221, ${opacity * 0.5})`; | |
| ctx.shadowBlur = reverbGlow; | |
| const primaryFreq = 3 + (snapshot.rootNote % 5) * (1 - reduction * 0.8); | |
| const secondaryFreq = 7 + (snapshot.steps % 6) * (1 - reduction * 0.8); | |
| const wobbleAmount = 0.5 * (0.5 + Math.sin(radius / this.radius * Math.PI) * 0.5); | |
| ctx.strokeStyle = `rgba(41, 41, 41, ${opacity})`; | |
| ctx.lineWidth = 0.75; | |
| ctx.beginPath(); | |
| for (let angle = 0; angle <= Math.PI * 2.05; angle += 0.05) { | |
| const pWobble = Math.sin(angle * primaryFreq + rotation) * wobbleAmount * 0.6; | |
| const sWobble = Math.sin(angle * secondaryFreq - rotation * 0.7) * wobbleAmount * 0.4; | |
| const r = radius + pWobble + sWobble; | |
| const x = centerX + r * Math.cos(angle); | |
| const y = centerY + r * Math.sin(angle); | |
| if (angle === 0) ctx.moveTo(x, y); | |
| else ctx.lineTo(x, y); | |
| } | |
| ctx.stroke(); | |
| const delay = snapshot?.delaySend ?? 0; | |
| if (delay > 0.1 && reduction < 0.9) { | |
| const ghostOpacity = opacity * delay * 0.3; | |
| const ghostRadius = radius * 0.85; | |
| this.drawSacredShape(ctx, centerX, centerY, ghostRadius, ghostOpacity, rotation * 0.2, { ...snapshot, delaySend: 0, reverbSend: 0 }, reduction); | |
| } | |
| ctx.shadowBlur = 0; | |
| } | |
| drawVisualizer() { | |
| this.ctx.fillStyle = 'rgba(0, 0, 0, 0.95)'; | |
| this.ctx.fillRect(0, 0, this.dom.phaseClock.width, this.dom.phaseClock.height); | |
| const centerX = this.radius; | |
| const centerY = this.radius; | |
| const now = Date.now(); | |
| this.ctx.strokeStyle = 'rgba(85, 85, 85, 0.25)'; | |
| this.ctx.lineWidth = 1; | |
| this.engine.tracks.forEach((track) => { | |
| if (!track.active) return; | |
| const trackRadius = this.radius * (0.35 + ((track.id % 16) * 0.038)); | |
| this.ctx.beginPath(); | |
| this.ctx.arc(centerX, centerY, trackRadius, 0, 2 * Math.PI); | |
| this.ctx.stroke(); | |
| }); | |
| if (!this.engine.isPlaying && (now - this.lastPulseTime > this.pulseInterval)) { | |
| this.idlePulses.push({ startTime: now, duration: 2500, maxRadius: this.radius * 0.85, isBypass: false }); | |
| this.lastPulseTime = now; | |
| this.pulseInterval = 1500 + Math.random() * 1500; | |
| } | |
| const reduction = parseFloat(this.dom.reductionSlider.value); | |
| const rotationSpeed = 0.5 + (this.engine.bpm / 120 - 1) * 0.3; | |
| this.idlePulses.forEach(pulse => { | |
| const age = now - pulse.startTime; | |
| const life = age / pulse.duration; | |
| if (life < 1) { | |
| const baseRadius = life * pulse.maxRadius; | |
| const opacity = Math.sin((1 - life) * Math.PI) * (pulse.isBypass ? 0.5 : 0.25); | |
| const rotation = (now / 2000 * rotationSpeed) * (pulse.trackId % 2 === 0 ? -1 : 1); | |
| if (pulse.isBypass && pulse.snapshot) { | |
| this.drawSacredShape(this.ctx, centerX, centerY, baseRadius, opacity, rotation, pulse.snapshot, reduction); | |
| } else { // Normal Ripple | |
| for (let i = 0; i < 3; i++) { | |
| const ringRadius = baseRadius * (1 - i * 0.15); | |
| if (ringRadius < 0) continue; | |
| const ringOpacity = opacity * (1 - i * 0.25); | |
| this.ctx.strokeStyle = `rgba(221, 221, 221, ${ringOpacity})`; // Softer off-white color | |
| this.ctx.lineWidth = 1.5 * (1 - life) * (1 - i * 0.1); | |
| this.ctx.beginPath(); | |
| this.ctx.arc(centerX, centerY, ringRadius, 0, 2 * Math.PI); | |
| this.ctx.stroke(); | |
| } | |
| } | |
| } | |
| }); | |
| // Draw track step markers | |
| this.engine.tracks.forEach((track) => { | |
| if (!track.active) return; | |
| const trackRadius = this.radius * (0.35 + ((track.id % 16) * 0.038)); | |
| let angle; | |
| if (this.engine.isPlaying) { | |
| angle = ((this.engine.masterStep % track.params.steps) / track.params.steps) * 2 * Math.PI - Math.PI / 2; | |
| } else { | |
| const speed = 1 / (2 + track.id % 4); | |
| angle = ((now / 10000) * speed % 1) * 2 * Math.PI - Math.PI / 2; | |
| angle += Math.sin(now / 700 + track.id) * 0.1; | |
| } | |
| const x = centerX + trackRadius * Math.cos(angle); | |
| const y = centerY + trackRadius * Math.sin(angle); | |
| const pulseRadius = 5 + track.pulse * 10; | |
| this.ctx.fillStyle = `rgba(204, 204, 204, 0.8)`; | |
| this.ctx.shadowColor = `rgba(221, 221, 221, ${track.pulse})`; | |
| this.ctx.shadowBlur = track.pulse * 15; | |
| this.ctx.beginPath(); | |
| this.ctx.arc(x, y, pulseRadius, 0, 2 * Math.PI); | |
| this.ctx.fill(); | |
| track.pulse *= 0.92; | |
| if (track.pulse < 0.01) track.pulse = 0; | |
| }); | |
| this.ctx.shadowBlur = 0; | |
| this.idlePulses = this.idlePulses.filter(pulse => (now - pulse.startTime) < pulse.duration); | |
| requestAnimationFrame(() => this.drawVisualizer()); | |
| } | |
| } | |
| window.addEventListener('load', () => { | |
| const engine = new AudioEngine(); | |
| const ui = new UIManager(engine); | |
| engine.uiManager = ui; | |
| ui.addTrackFromPreset('kick', 'dubtechno'); | |
| const chordTrack = ui.addTrackFromPreset('dubchord'); | |
| if (chordTrack) { | |
| chordTrack.randomize(); | |
| if (Math.random() < 0.5) { | |
| chordTrack.shuffleOctaves(); | |
| } | |
| } | |
| const bassTypes = ['subbass', 'midbass', 'dubbass', 'eskibass']; | |
| const randomBassType = bassTypes[Math.floor(Math.random() * bassTypes.length)]; | |
| const bassTrack = ui.addTrackFromPreset(randomBassType); | |
| if (bassTrack) { | |
| bassTrack.randomize(); | |
| if (bassTrack.synth !== 'eskibass') { | |
| bassTrack.params.fxBypass = true; | |
| } | |
| bassTrack.updateUI(); | |
| } | |
| const stabTypes = ['funkStab', 'glassystab', 'stab', 'shortpad', 'lead']; | |
| const randomStabType = stabTypes[Math.floor(Math.random() * stabTypes.length)]; | |
| const stabTrack = ui.addTrackFromPreset(randomStabType); | |
| if (stabTrack) { | |
| stabTrack.randomize(); | |
| } | |
| const allPresets = Object.keys(PRESETS); | |
| const randomPresetKey = allPresets[randomInt(allPresets.length)]; | |
| const randomTrack = ui.addTrackFromPreset(randomPresetKey); | |
| if (randomTrack) { | |
| randomTrack.randomize(); | |
| } | |
| ui.dom.tracksWrapper.classList.add('expanded'); | |
| ui.dom.toggleTracksBtn.textContent = 'SHOW LESS'; | |
| ui.dom.globalProbabilitySlider.dispatchEvent(new Event('input', { bubbles: true })); | |
| ui.dom.ghostNotesSlider.dispatchEvent(new Event('input', { bubbles: true })); | |
| const masterEffectsPanel = document.getElementById('master-effects-panel'); | |
| if (masterEffectsPanel) { | |
| const rndInput = masterEffectsPanel.querySelector('.randomize-interval-input:not([title*="walk"])'); | |
| if (rndInput) { | |
| rndInput.value = '8'; | |
| engine.setPanelAutomation('master-effects-panel', 'rnd', 8); | |
| } | |
| } | |
| ui.drawVisualizer(); | |
| console.log("SYSTEM INITIALIZED - Press 'PLAY'"); | |
| }); | |
| </script> | |
| </body> | |
| </html> |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment