A Pen by semanticentity on CodePen.
Created
October 15, 2025 23:42
-
-
Save semanticentity/6e2b10febe87c957835c6e236bbb37ff 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.top-control-panel .randomize-panel-btn { | |
| display: none; | |
| } | |
| .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; | |
| } | |
| /* Give immediate feedback on touch */ | |
| button:active { | |
| transform: scale(0.97); | |
| border-color: var(--accent); | |
| color: var(--accent); | |
| } | |
| 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; } | |
| /* --- Wipe Button Styles --- */ | |
| @keyframes pulse-white-border { | |
| 0% { border-color: #fff; box-shadow: 0 0 0 0 rgba(255, 255, 255, 0.4); } | |
| 70% { box-shadow: 0 0 0 10px rgba(255, 255, 255, 0); } | |
| 100% { border-color: #fff; box-shadow: 0 0 0 0 rgba(41, 41, 41, 0); } | |
| } | |
| button#wipe-btn { | |
| background-color: var(--darkred-color); | |
| color: var(--accent); | |
| border: 1px solid #fff; | |
| transition: background-color 0.3s ease-in-out; | |
| } | |
| button#wipe-btn:hover { | |
| background-color: #111; | |
| animation: pulse-white-border 0.85s infinite; | |
| } | |
| /* --- END Wipe Button Styles --- */ | |
| #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; | |
| } | |
| /* --- Keyframe for track border pulse --- */ | |
| @keyframes pulse-border { | |
| 0% { | |
| border-left-color: var(--accent-tertiary); | |
| box-shadow: inset -1px 0 3px -1px darkred, inset 3px 0 10px -3px var(--darkred-color); | |
| } | |
| 100% { | |
| border-left-color: var(--accent-secondary); | |
| box-shadow: 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 { | |
| animation: pulse-border 1.5s ease-out; | |
| } | |
| .track.muted { opacity: 0.6; background: #111; } | |
| .track.soloed { border-left-color: var(--solo-color); } | |
| .track.inactive-solo { opacity: 0.5; } | |
| .track.skipping { | |
| border-left-color: var(--border-color) !important; | |
| opacity: 0.5; | |
| background: var(--bg); | |
| } | |
| .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, | |
| .track-controls .edit-track-btn /* NEW */ | |
| { | |
| 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: 76px; | |
| width: 66%; | |
| } | |
| .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); | |
| } | |
| @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); | |
| } | |
| /* --- 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; | |
| position: relative; /* Added for ::before positioning */ | |
| } | |
| .modal-content h3#modal-title, | |
| .modal-content h3#step-modal-title { | |
| padding: 20px 20px 10px 20px; | |
| flex-shrink: 0; | |
| } | |
| #modal-scroll-container, | |
| #step-modal-scroll-container { | |
| overflow-y: auto; | |
| flex-grow: 1; | |
| padding: 10px 20px; | |
| } | |
| #modal-scroll-container::-webkit-scrollbar, | |
| #step-modal-scroll-container::-webkit-scrollbar { | |
| width: 8px; | |
| } | |
| #modal-scroll-container::-webkit-scrollbar-track, | |
| #step-modal-scroll-container::-webkit-scrollbar-track { | |
| background: var(--panel-bg); | |
| } | |
| #modal-scroll-container::-webkit-scrollbar-thumb, | |
| #step-modal-scroll-container::-webkit-scrollbar-thumb { | |
| background: var(--accent-dark); border-radius: 4px; | |
| } | |
| #modal-footer, | |
| #step-modal-footer { | |
| flex-shrink: 0; | |
| padding: 15px 20px 20px 20px; | |
| background-color: var(--bg); | |
| border-top: 1px solid var(--border-color); | |
| } | |
| #modal-footer .row, | |
| #step-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; | |
| } | |
| .modal-content .row.disabled-control { | |
| opacity: 0.5; | |
| } | |
| #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; | |
| } | |
| /* --- MOBILE FIXES --- */ | |
| @media (max-width: 460px) { | |
| body, html { | |
| overflow: auto; /* Allow scrolling on mobile */ | |
| } | |
| #app-container { | |
| /* Stack controls on top of visualizer */ | |
| grid-template-columns: 1fr; | |
| grid-template-rows: auto 1fr; | |
| height: auto; | |
| width: 100%; /* Prevent 100vw from causing overflow */ | |
| } | |
| #controls { | |
| border-right: none; | |
| border-bottom: 1px solid var(--border-color); | |
| overflow-y: visible; | |
| } | |
| #visualizer-container { | |
| display: none; | |
| } | |
| .row { | |
| flex-wrap: wrap; | |
| } | |
| label { | |
| flex-basis: 100%; | |
| text-align: left; | |
| margin-bottom: 5px; | |
| } | |
| .track { | |
| grid-template-columns: 1fr; /* Simplified for mobile */ | |
| grid-template-rows: auto auto; | |
| gap: 8px; /* Added gap between controls and steps */ | |
| } | |
| .track-controls { | |
| flex-wrap: wrap; /* Allow buttons to wrap */ | |
| } | |
| .steps-container { | |
| grid-column: 1 / -1; | |
| margin-left: 0; | |
| margin-top: 0; /* Removed margin */ | |
| width: 100%; | |
| } | |
| .track-info { | |
| display: none; | |
| } | |
| /* --- FIX 1: Remove excessive padding causing horizontal scroll --- */ | |
| .control-panel.collapsible h3 { | |
| padding-right: 40px; /* Reduce padding to just avoid the +/- icon */ | |
| } | |
| /* --- FIX 2: Make sliders scroll-friendly on touch devices --- */ | |
| input[type="range"] { | |
| pointer-events: none; /* Disable clicks on the track itself */ | |
| } | |
| input[type="range"]::-webkit-slider-thumb { | |
| pointer-events: auto; /* RE-ENABLE events only for the thumb */ | |
| width: 28px; /* Make the thumb larger and easier to grab */ | |
| height: 28px; | |
| } | |
| input[type="range"]::-moz-range-thumb { | |
| pointer-events: auto; /* Firefox support */ | |
| width: 28px; | |
| height: 28px; | |
| } | |
| /* --- NEW: BOTTOM SHEET MODAL STYLES --- */ | |
| /* Override existing modal styles for mobile */ | |
| .modal-overlay { | |
| /* On mobile, align to the bottom */ | |
| align-items: flex-end; | |
| /* Remove backdrop blur on mobile for performance */ | |
| backdrop-filter: none; | |
| } | |
| .modal-content { | |
| /* Make it full width */ | |
| width: 100%; | |
| max-width: 100%; | |
| /* Give it rounded top corners */ | |
| border-radius: 16px 16px 0 0; | |
| /* Animate it sliding up */ | |
| transform: translateY(100%); | |
| transition: transform 0.3s ease-out; | |
| /* Don't let it take up the whole screen */ | |
| max-height: 85vh; | |
| } | |
| /* When the modal is shown, slide it into view */ | |
| .modal-overlay.visible .modal-content { | |
| transform: translateY(0); | |
| } | |
| /* Optional: Add a little grabber handle */ | |
| .modal-content::before { | |
| content: ''; | |
| position: absolute; | |
| top: 8px; | |
| left: 50%; | |
| transform: translateX(-50%); | |
| width: 40px; | |
| height: 4px; | |
| background-color: var(--border-color); | |
| border-radius: 2px; | |
| } | |
| .modal-content h3#modal-title, | |
| .modal-content h3#step-modal-title { | |
| padding-top: 24px; /* Make space for the grabber */ | |
| } | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| <div id="app-container"> | |
| <div id="controls"> | |
| <h1>SEQUENCER<span>:</span>SYSTEM</h1> | |
| <div class="control-panel top-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> | |
| <!-- NEW: STEP EDITOR MODAL --> | |
| <div id="step-editor-modal" class="modal-overlay"> | |
| <div class="modal-content"> | |
| <h3 id="step-modal-title">EDIT STEP</h3> | |
| <div id="step-modal-scroll-container" class="modal-scroll-container"> | |
| <div id="step-modal-body"> | |
| <!-- Content will be injected here --> | |
| </div> | |
| </div> | |
| <div id="step-modal-footer" class="modal-footer"> | |
| <div class="row"> | |
| <button id="step-modal-close">DONE</button> | |
| </div> | |
| </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 } }, | |
| }, | |
| stab: { // <-- FIX: ADDED THIS ENTIRE BLOCK | |
| default: { | |
| synth: 'stab', | |
| mode: 'GATE', | |
| params: { | |
| delaySend: 0.6, | |
| reverbSend: 0.4, | |
| sequence: [false, false, true, false, false, true, false, false, false, false, false, true, false, false, true, false] | |
| } | |
| } | |
| }, | |
| 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, filterQ: 7.5 } } }, | |
| 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.tracks.forEach(t => { t.loopState = 'PLAYING'; t.loopCounter = 0; }); | |
| 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.loopState !== 'SKIPPING' && | |
| 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.params.steps > 0 && step > 0 && step % track.params.steps === 0) { | |
| track.onNewLoopStart(); | |
| } | |
| track.ui.element.classList.toggle('skipping', track.loopState === 'SKIPPING'); | |
| if (!track.active || track.mute || (anySolo && !track.solo) || track.loopState === 'SKIPPING') 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'), 200); // Increased time for animation | |
| } | |
| } | |
| 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 p = track.params; // Use 'p' for easier access | |
| const stepParams = p.stepParams[step] || {}; | |
| let velocity = overrideVelocity ?? stepParams.velocity ?? p.volume; | |
| const isAccented = stepParams.accent ?? false; | |
| // --- NEW MODULATION LOGIC --- | |
| // Create a temporary 'modulatedParams' object to hold the final values for this step | |
| const modulatedParams = { ...p }; | |
| if (isAccented) { | |
| // Apply modulations from the track's accentMods settings | |
| velocity *= (1 + p.accentMods.velocity); | |
| // Modulate envelope decay (as a multiplier) | |
| if (modulatedParams.decay) { | |
| modulatedParams.decay *= (1 + p.accentMods.decay); | |
| } | |
| // Modulate filter cutoff (add a value, more intuitive than multiply) | |
| if (modulatedParams.filterCutoff) { | |
| // We'll modulate up to +/- 5000 Hz, which is a good range | |
| modulatedParams.filterCutoff += p.accentMods.filterCutoff * 5000; | |
| } | |
| // Modulate filter resonance (Q) | |
| if (modulatedParams.filterQ) { | |
| modulatedParams.filterQ += p.accentMods.filterQ * 10; // Modulate up to +/- 10 Q | |
| } | |
| } | |
| // Make sure velocity is not negative or excessively loud | |
| velocity = Math.max(0, Math.min(1.5, velocity)); | |
| // Apply pitch modulation (from both accent and octave shift) | |
| let octaveShift = stepParams.octave ?? 0; | |
| if (isAccented) { | |
| // Modulate by up to +/- 12 semitones (one octave) | |
| octaveShift += p.accentMods.pitch * 12; | |
| } | |
| // --- END NEW MODULATION LOGIC --- | |
| 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 = !modulatedParams.fxBypass; | |
| if (isRoutedLowEnd && this.dubSendMode === 'integrated') { | |
| useFxPathForMainSignal = true; | |
| } | |
| if (useFxPathForMainSignal) { | |
| const destinationBus = (isKick || isBass) ? this.kickBus : this.sidechainBus; | |
| const filterChain = this.createTrackFilterChain(modulatedParams, destinationBus); | |
| gainNode.connect(filterChain.input); | |
| if (modulatedParams.modSend > 0) { const send = this.audioCtx.createGain(); send.gain.value = modulatedParams.modSend; gainNode.connect(send); send.connect(this.modFxBus); } | |
| if (modulatedParams.delaySend > 0) { const send = this.audioCtx.createGain(); send.gain.value = modulatedParams.delaySend; gainNode.connect(send); send.connect(this.masterDelaySend); } | |
| if (modulatedParams.reverbSend > 0) { const send = this.audioCtx.createGain(); send.gain.value = modulatedParams.reverbSend; gainNode.connect(send); send.connect(this.masterReverbSend); } | |
| } else { | |
| const filterChain = this.createTrackFilterChain(modulatedParams, this.compressor); | |
| gainNode.connect(filterChain.input); | |
| } | |
| if (isRoutedLowEnd && this.dubSendMode === 'parallel') { | |
| if (modulatedParams.modSend > 0) { const send = this.audioCtx.createGain(); send.gain.value = modulatedParams.modSend; gainNode.connect(send); send.connect(this.modFxBus); } | |
| if (modulatedParams.delaySend > 0) { const send = this.audioCtx.createGain(); send.gain.value = modulatedParams.delaySend; gainNode.connect(send); send.connect(this.masterDelaySend); } | |
| if (modulatedParams.reverbSend > 0) { const send = this.audioCtx.createGain(); send.gain.value = modulatedParams.reverbSend; gainNode.connect(send); send.connect(this.masterReverbSend); } | |
| } | |
| const pitchMultiplier = Math.pow(2, ((modulatedParams.rootNote - 60) / 12)); | |
| switch(track.synth) { | |
| case 'kick': this.playKick(time, modulatedParams, gainNode, pitchMultiplier, octaveShift, gate); break; | |
| case 'kick909': this.playKick909(time, modulatedParams, gainNode, pitchMultiplier, octaveShift, gate); break; | |
| case 'snare': this.playSnare(time, modulatedParams, gainNode, pitchMultiplier, octaveShift, gate); break; | |
| case 'clap': this.playClap(time, modulatedParams, gainNode, pitchMultiplier, octaveShift, gate); break; | |
| case 'rimshot': this.playRimshot(time, modulatedParams, gainNode, pitchMultiplier, octaveShift, gate); break; | |
| case 'hat': this.playHat(time, modulatedParams, gainNode, pitchMultiplier, octaveShift, gate); break; | |
| case 'hat909': this.playHat909(time, modulatedParams, gainNode, pitchMultiplier, octaveShift, gate); break; | |
| case 'shaker': this.playShaker(time, modulatedParams, gainNode, pitchMultiplier, octaveShift, gate); break; | |
| case 'cowbell': this.playCowbell(time, modulatedParams, gainNode, octaveShift, gate); break; | |
| case 'clave': this.playClave(time, modulatedParams, gainNode, octaveShift, gate); break; | |
| case 'stab': this.playStab(time, modulatedParams, gainNode, octaveShift, gate); break; | |
| case 'funkstab': this.playFunkStab(time, modulatedParams, gainNode, octaveShift, gate); break; | |
| case 'funkmute': this.playFunkMute(time, modulatedParams, gainNode, octaveShift, gate); break; | |
| case 'dubguitarriff': this.playDubGuitarLick(time, modulatedParams, gainNode, octaveShift, gate); break; | |
| case 'synthfunklick': this.playSynthFunkLick(time, modulatedParams, gainNode, octaveShift, gate); break; | |
| case 'dubchord': this.playDubChord(time, modulatedParams, gainNode, octaveShift, gate); break; | |
| case 'drychord': this.playDryChord(time, modulatedParams, gainNode, octaveShift, gate); break; | |
| case 'subbass': this.playSubBass(time, modulatedParams, gainNode, octaveShift, gate); break; | |
| case 'reese': this.playReeseBass(time, modulatedParams, gainNode, octaveShift, gate); break; | |
| case 'eskibass': this.playEskiBass(time, modulatedParams, gainNode, octaveShift, gate); break; | |
| case 'lead': this.playLead(time, modulatedParams, gainNode, octaveShift, gate); break; | |
| case 'melodica': this.playMelodica(time, modulatedParams, gainNode, octaveShift, gate); break; | |
| case 'synthflute': this.playSynthFlute(time, modulatedParams, gainNode, octaveShift, gate); break; | |
| case 'pad': this.playDeepPad(time, modulatedParams, gainNode, octaveShift, gate); break; | |
| case 'shortpad': this.playShortPad(time, modulatedParams, gainNode, octaveShift, gate); break; | |
| case 'vibraphone': this.playVibraphone(time, modulatedParams, gainNode, octaveShift, gate); break; | |
| case 'noise': this.playNoise(time, modulatedParams, gainNode, pitchMultiplier, octaveShift, gate); break; | |
| case 'siren': this.playSiren(time, modulatedParams, gainNode, octaveShift, gate); break; | |
| case 'siren_aggro': this.playAggroSiren(time, modulatedParams, gainNode, octaveShift, gate); break; | |
| case 'sfx_tuningtone': this.playTuningTone(time, modulatedParams, gainNode, octaveShift, gate); break; | |
| case 'sfx_manualsweep': this.playManualSweep(time, modulatedParams, 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, modulatedParams, gainNode, pitchMultiplier, octaveShift, gate); break; | |
| case 'sfx_clink': this.playSfxClink(time, modulatedParams, 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 = p.filterQ; | |
| 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.loopState = 'PLAYING'; // 'PLAYING' or 'SKIPPING' | |
| this.loopCounter = 0; // How many loops have passed in the current state | |
| 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, | |
| // --- NEW ACCENT MODS OBJECT --- | |
| accentMods: { | |
| velocity: 0.4, // Default to a 40% velocity boost (replaces the old hardcoded 1.4x) | |
| decay: 0, | |
| filterCutoff: 0, | |
| filterQ: 0, | |
| pitch: 0 | |
| }, | |
| useGlobalSwing: true, | |
| swingAmount: 0.1, | |
| loopLogicEnabled: false, | |
| loopProbability: 1.0, | |
| playLoops: 1, | |
| skipLoops: 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, filterQ: 7.5 }; | |
| 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; | |
| } | |
| } | |
| onNewLoopStart() { | |
| if (this.params.loopLogicEnabled) { | |
| // --- DETERMINISTIC "Play N / Skip N" LOGIC --- | |
| this.loopCounter++; | |
| if (this.loopState === 'PLAYING') { | |
| if (this.loopCounter >= this.params.playLoops) { | |
| // Play cycle is complete. Switch to skipping. | |
| this.loopState = 'SKIPPING'; | |
| this.loopCounter = 0; // Reset for the new 'SKIPPING' state | |
| } | |
| } else { // loopState is 'SKIPPING' | |
| if (this.loopCounter >= this.params.skipLoops) { | |
| // Skip cycle is complete. Switch to playing. | |
| this.loopState = 'PLAYING'; | |
| this.loopCounter = 0; // Reset for the new 'PLAYING' state | |
| } | |
| } | |
| } else { | |
| // --- SIMPLE PROBABILITY LOGIC --- | |
| // On every new loop, decide whether to play or skip based on probability. | |
| if (Math.random() > this.params.loopProbability) { | |
| this.loopState = 'SKIPPING'; | |
| } else { | |
| this.loopState = 'PLAYING'; | |
| } | |
| } | |
| } | |
| 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(); | |
| this.uiManager.refreshTooltip(); // <--- ADDED THIS CALL | |
| }; | |
| // The title will now be set dynamically in the updateUI method | |
| controlsEl.appendChild(nameBtn); | |
| // NEW: DEDICATED EDIT BUTTON | |
| const editBtn = document.createElement('button'); | |
| editBtn.textContent = '...'; | |
| editBtn.className = 'edit-track-btn'; // You can style this | |
| editBtn.title = 'Edit Track Settings'; | |
| editBtn.onclick = () => this.uiManager.openModal(this); | |
| controlsEl.appendChild(editBtn); | |
| 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); } | |
| // --- NEW: REWORKED STEP EVENT LISTENERS --- | |
| // REMOVE OLD DESKTOP-FIRST HANDLERS | |
| stepsEl.onmousedown = null; | |
| stepsEl.onmousemove = null; | |
| stepsEl.onmouseleave = null; | |
| // ADD NEW MOBILE-FRIENDLY CLICK HANDLER | |
| stepsEl.onclick = (e) => { | |
| e.preventDefault(); | |
| const stepEl = e.target.closest('.step'); | |
| if (!stepEl) return; | |
| const stepIndex = parseInt(stepEl.dataset.index, 10); | |
| // Simple toggle logic for mobile tap | |
| const isCurrentlyOn = this.shouldTrigger(stepIndex); | |
| this.setStepState(stepIndex, !isCurrentlyOn); | |
| this.updateUI(); | |
| }; | |
| // ADD NEW LONG-PRESS / RIGHT-CLICK HANDLER FOR THE STEP EDITOR | |
| this.steps.forEach((stepEl) => { | |
| // Remove old context menu handler | |
| stepEl.oncontextmenu = null; | |
| // For mobile: Use long press for the step editor | |
| let pressTimer; | |
| stepEl.addEventListener('touchstart', (e) => { | |
| pressTimer = window.setTimeout(() => { | |
| e.preventDefault(); // Prevent click from firing after the long press | |
| const index = parseInt(stepEl.dataset.index, 10); | |
| this.uiManager.openStepEditor(this, index); | |
| }, 500); // 500ms for long press | |
| }, { passive: true }); // Use passive to improve scroll performance | |
| stepEl.addEventListener('touchend', () => { | |
| clearTimeout(pressTimer); | |
| }); | |
| stepEl.addEventListener('touchmove', () => { // Cancel long press if user scrolls | |
| clearTimeout(pressTimer); | |
| }); | |
| // For desktop compatibility, we can keep right-click | |
| stepEl.addEventListener('contextmenu', (e) => { | |
| e.preventDefault(); | |
| const index = parseInt(stepEl.dataset.index, 10); | |
| this.uiManager.openStepEditor(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; if (s.steps <= 0) return false; 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; } | |
| getTooltipSummary() { | |
| const p = this.params; | |
| const info = ["Click: Randomize", "---"]; | |
| if (this.mode === 'EUCLID') { | |
| info.push(`MODE: EUCLID (${p.pulses}/${p.steps}, offset ${p.offset})`); | |
| } else { | |
| info.push(`MODE: GATE (${p.steps} steps)`); | |
| } | |
| 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 (melodicSynths.includes(this.synth)) { | |
| const noteNames = ["C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B"]; | |
| const note = noteNames[p.rootNote % 12]; | |
| const octave = Math.floor(p.rootNote / 12) - 1; | |
| info.push(`NOTE: ${note}${octave} (${p.rootNote}) | ${p.scale}`); | |
| } | |
| if (p.loopLogicEnabled && (p.playLoops > 1 || p.skipLoops > 0)) { | |
| info.push(`LOOP: Play ${p.playLoops}, Skip ${p.skipLoops}`); | |
| } else if (p.loopProbability < 1.0) { | |
| info.push(`PROB: ${Math.round(p.loopProbability * 100)}%`); | |
| } | |
| return info.join('\n'); | |
| } | |
| 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()}`; | |
| nameBtn.title = this.getTooltipSummary(); | |
| } | |
| 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.currentTooltipTarget = null; // Add this new property to track the element | |
| 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'), | |
| // NEW: Step Editor DOM elements | |
| stepEditorModal: document.getElementById('step-editor-modal'), | |
| stepModalTitle: document.getElementById('step-modal-title'), | |
| stepModalBody: document.getElementById('step-modal-body'), | |
| stepModalClose: document.getElementById('step-modal-close'), | |
| }; | |
| 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.closeModal(this.dom.genreModal); | |
| 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'); | |
| appContainer.addEventListener('mouseover', e => { | |
| const target = this.getTooltipTarget(e.target); | |
| if (target && !this.isDraggingStepVertically) { | |
| this.currentTooltipTarget = target; // <-- STORE the 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(); | |
| this.currentTooltipTarget = null; // <-- CLEAR the target | |
| } | |
| }); | |
| appContainer.addEventListener('mousemove', e => { | |
| if (this.currentTooltipTarget && !this.isDraggingStepVertically) { | |
| this.updateTooltipPosition(e); | |
| } | |
| }); | |
| } | |
| // NEW METHOD | |
| refreshTooltip() { | |
| // Check if a tooltip is currently supposed to be visible for a specific element | |
| if (this.currentTooltipTarget && !this.dom.tooltip.classList.contains('hidden')) { | |
| const newText = this.getTooltipText(this.currentTooltipTarget); | |
| if (newText) { | |
| this.dom.tooltip.innerHTML = newText; | |
| } else { | |
| this.hideTooltip(); // Hide if the new state has no tooltip | |
| } | |
| } | |
| } | |
| 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.closeModal(this.dom.genreModal); }; this.dom.genreModalBody.appendChild(btn); } | |
| this.openModal(null, this.dom.genreModal); | |
| } | |
| // NEW: REFACTORED MODAL LOGIC | |
| openModal(track, modalElement = this.dom.modal) { | |
| if (track) { // This condition is for the main track editor | |
| this.modalOriginalTrackState = { | |
| active: track.active, | |
| synth: track.synth, | |
| mode: track.mode, | |
| params: JSON.parse(JSON.stringify(track.params)) | |
| }; | |
| 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.closeModal(modalElement); | |
| this.modalOriginalTrackState = null; | |
| }; | |
| this.dom.modalSave.onclick = () => { this.closeModal(modalElement); this.modalOriginalTrackState = null; }; | |
| this.dom.modalDelete.onclick = () => { this.removeTrack(track.id); this.closeModal(modalElement); this.modalOriginalTrackState = null; }; | |
| } | |
| modalElement.style.display = 'flex'; | |
| setTimeout(() => modalElement.classList.add('visible'), 10); | |
| } | |
| closeModal(modalElement) { | |
| modalElement.classList.remove('visible'); | |
| setTimeout(() => { | |
| modalElement.style.display = 'none'; | |
| }, 300); // Match CSS transition duration | |
| } | |
| // NEW: openStepEditor | |
| openStepEditor(track, stepIndex) { | |
| this.dom.stepModalTitle.textContent = `EDIT STEP ${stepIndex + 1}`; | |
| this.dom.stepModalBody.innerHTML = this.getStepEditorContent(track, stepIndex); | |
| // Show the modal | |
| this.openModal(null, this.dom.stepEditorModal); | |
| const form = this.dom.stepModalBody.querySelector('form'); | |
| const updateStepFromEditor = () => { | |
| const sp = track.params.stepParams[stepIndex] || {}; | |
| sp.accent = form.querySelector('[name="accent"]').checked; | |
| sp.velocity = parseFloat(form.querySelector('[name="velocity"]').value); | |
| sp.octave = parseFloat(form.querySelector('[name="octave"]').value); | |
| sp.gate = parseFloat(form.querySelector('[name="gate"]').value); | |
| const isDefault = !sp.accent && | |
| sp.velocity === track.params.volume && | |
| sp.octave === 0 && | |
| sp.gate === 1.0; | |
| if (isDefault) { | |
| delete track.params.stepParams[stepIndex]; | |
| } else { | |
| track.params.stepParams[stepIndex] = sp; | |
| } | |
| track.updateUI(); | |
| }; | |
| form.addEventListener('input', updateStepFromEditor); | |
| const closeEditor = () => { | |
| this.closeModal(this.dom.stepEditorModal); | |
| setTimeout(() => { | |
| this.dom.stepModalBody.innerHTML = ''; // Clean up after animation | |
| }, 300); | |
| }; | |
| this.dom.stepModalClose.onclick = closeEditor; | |
| this.dom.stepEditorModal.onclick = (e) => { | |
| if (e.target === this.dom.stepEditorModal) { | |
| closeEditor(); | |
| } | |
| }; | |
| } | |
| // NEW: getStepEditorContent | |
| getStepEditorContent(track, stepIndex) { | |
| const sp = track.params.stepParams[stepIndex] || {}; | |
| const p = (name, def) => sp[name] ?? def; | |
| return `<form onsubmit="return false;"> | |
| <div class="row" style="padding: 10px 0;"> | |
| <label class="stretch" style="font-size: 1.2em;">Accent</label> | |
| <input type="checkbox" name="accent" ${p('accent', false) ? 'checked' : ''}> | |
| </div> | |
| <hr> | |
| <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"> | |
| </div> | |
| <div class="row"> | |
| <label>Octave Shift</label> | |
| <input type="range" name="octave" value="${p('octave', 0)}" min="-2" max="2" step="0.5"> | |
| </div> | |
| <div class="row"> | |
| <label>Gate</label> | |
| <input type="range" name="gate" value="${p('gate', 1.0)}" min="0.1" max="2" step="0.05"> | |
| </div> | |
| </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 loopBehaviorControls = `<hr><h3>LOOP BEHAVIOR</h3> | |
| <div class="row ${p.loopLogicEnabled ? 'disabled-control' : ''}"> | |
| <label>Probability</label> | |
| <input type="range" name="loopProbability" value="${p.loopProbability}" min="0" max="1" step="0.01" ${p.loopLogicEnabled ? 'disabled' : ''}> | |
| <span class="value-display">${Math.round(p.loopProbability*100)}%</span> | |
| </div> | |
| <div class="row"> | |
| <label class="stretch">Enable Loop Logic</label> | |
| <input type="checkbox" name="loopLogicEnabled" ${p.loopLogicEnabled ? 'checked' : ''}> | |
| </div> | |
| <div class="row"> | |
| <label>Play For (N Loops)</label> | |
| <input type="number" name="playLoops" value="${p.playLoops}" min="1" max="64" ${p.loopLogicEnabled ? '' : 'disabled'}> | |
| </div> | |
| <div class="row"> | |
| <label>Skip For (N Loops)</label> | |
| <input type="number" name="skipLoops" value="${p.skipLoops}" min="0" max="64" ${p.loopLogicEnabled ? '' : 'disabled'}> | |
| </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}${loopBehaviorControls}${mixControls}${musicalControls}${synthControls} | |
| <!-- --- NEW ACCENT PATCH BAY UI --- --> | |
| <hr><h3>ACCENT MODULATION</h3> | |
| <div class="row"> | |
| <label>Velocity</label> | |
| <input type="range" name="accentMod_velocity" value="${p.accentMods?.velocity ?? 0.4}" min="-1" max="2" step="0.01"> | |
| <span class="value-display">${Math.round((p.accentMods?.velocity ?? 0.4) * 100)}%</span> | |
| </div> | |
| <div class="row"> | |
| <label>Decay</label> | |
| <input type="range" name="accentMod_decay" value="${p.accentMods?.decay ?? 0}" min="-0.9" max="2" step="0.01"> | |
| <span class="value-display">${Math.round((p.accentMods?.decay ?? 0) * 100)}%</span> | |
| </div> | |
| <div class="row"> | |
| <label>Filter Cutoff</label> | |
| <input type="range" name="accentMod_filterCutoff" value="${p.accentMods?.filterCutoff ?? 0}" min="-1" max="1" step="0.01"> | |
| <span class="value-display">${Math.round((p.accentMods?.filterCutoff ?? 0) * 100)}%</span> | |
| </div> | |
| <div class="row"> | |
| <label>Filter Reso</label> | |
| <input type="range" name="accentMod_filterQ" value="${p.accentMods?.filterQ ?? 0}" min="-1" max="1" step="0.01"> | |
| <span class="value-display">${Math.round((p.accentMods?.filterQ ?? 0) * 100)}%</span> | |
| </div> | |
| <div class="row"> | |
| <label>Pitch</label> | |
| <input type="range" name="accentMod_pitch" value="${p.accentMods?.pitch ?? 0}" min="-1" max="1" step="0.01"> | |
| <span class="value-display">${((p.accentMods?.pitch ?? 0) * 12).toFixed(1)} st</span> | |
| </div> | |
| </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; | |
| const oldLoopLogicState = track.params.loopLogicEnabled; | |
| // Ensure the accentMods object exists | |
| if (!track.params.accentMods) { | |
| track.params.accentMods = {}; | |
| } | |
| form.querySelectorAll('input, select').forEach(input => { | |
| const name = input.name; | |
| if (!name) return; | |
| // --- NEW LOGIC for Accent Mods --- | |
| if (name.startsWith('accentMod_')) { | |
| const key = name.split('_')[1]; | |
| track.params.accentMods[key] = parseFloat(input.value); | |
| // Update value displays | |
| const display = input.nextElementSibling; | |
| if (display) { | |
| if (key === 'pitch') { | |
| display.textContent = (parseFloat(input.value) * 12).toFixed(1) + ' st'; | |
| } else { | |
| display.textContent = Math.round(parseFloat(input.value) * 100) + '%'; | |
| } | |
| } | |
| return; // Continue to next input | |
| } | |
| // --- END NEW LOGIC --- | |
| 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; } | |
| if (name === 'loopProbability') { | |
| const display = input.nextElementSibling; | |
| if(display) display.textContent = `${Math.round(value*100)}%`; | |
| } | |
| }); | |
| if (track.params.loopLogicEnabled !== oldLoopLogicState) { | |
| track.loopState = 'PLAYING'; | |
| track.loopCounter = 0; | |
| } | |
| // Handle dependent UI elements | |
| 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)}%`; | |
| } | |
| } | |
| const loopLogicCheckbox = form.querySelector('[name="loopLogicEnabled"]'); | |
| const playLoopsInput = form.querySelector('[name="playLoops"]'); | |
| const skipLoopsInput = form.querySelector('[name="skipLoops"]'); | |
| const probabilityInput = form.querySelector('[name="loopProbability"]'); | |
| if (loopLogicCheckbox && playLoopsInput && skipLoopsInput && probabilityInput) { | |
| const isEnabled = loopLogicCheckbox.checked; | |
| playLoopsInput.disabled = !isEnabled; | |
| skipLoopsInput.disabled = !isEnabled; | |
| probabilityInput.disabled = isEnabled; | |
| const probabilityRow = probabilityInput.closest('.row'); | |
| if (probabilityRow) { | |
| probabilityRow.classList.toggle('disabled-control', isEnabled); | |
| } | |
| } | |
| 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.closeModal(this.dom.genreModal); | |
| }; | |
| this.dom.genreModalBody.appendChild(btn); | |
| }); | |
| this.openModal(null, this.dom.genreModal); | |
| } | |
| 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 && this.engine.audioCtx) { | |
| const secondsPerStep = 15.0 / this.engine.bpm; | |
| const elapsedSinceNextNote = this.engine.audioCtx.currentTime - this.engine.nextNoteTime; | |
| const currentStepProgress = ((this.engine.masterStep + 1) + (elapsedSinceNextNote / secondsPerStep)) % track.params.steps; | |
| angle = (currentStepProgress / 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