Skip to content

Instantly share code, notes, and snippets.

@semanticentity
Created October 15, 2025 23:42
Show Gist options
  • Select an option

  • Save semanticentity/6e2b10febe87c957835c6e236bbb37ff to your computer and use it in GitHub Desktop.

Select an option

Save semanticentity/6e2b10febe87c957835c6e236bbb37ff to your computer and use it in GitHub Desktop.
SEQUENCER:SYSTEM
<!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