Skip to content

Instantly share code, notes, and snippets.

@semanticentity
Created August 30, 2025 02:27
Show Gist options
  • Select an option

  • Save semanticentity/7961c4d4ff9ee6d3aa023ae7f0e88513 to your computer and use it in GitHub Desktop.

Select an option

Save semanticentity/7961c4d4ff9ee6d3aa023ae7f0e88513 to your computer and use it in GitHub Desktop.
WHITE DWARF
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>WHITE DWARF</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=Doto:wght,ROND@100..900,100&family=Roboto+Mono:wght@100..700&display=swap" rel="stylesheet">
<style>
:root {
--main-bg: #111113;
--panel-bg: rgba(28, 28, 32, 0.85);
--text-color: #d8d8d8;
--accent-color: #ff8c00;
--accent-hover: #ffaf4d;
--border-color: #444;
--shadow-color: rgba(255, 140, 0, 0.2);
--lyra-color: #00aaff;
--lyra-shadow: rgba(0, 170, 255, 0.2);
}
body,
html {
margin: 0;
padding: 0;
overflow: hidden;
width: 100%;
height: 100%;
background: var(--main-bg);
color: var(--text-color);
font-family: 'Roboto Mono', 'Courier New', monospace;
font-weight: 300;
font-size: 13px;
}
#canvas-container {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 1;
opacity: 0.8;
}
#controls {
position: fixed;
top: 10px;
left: 10px;
z-index: 10;
background: var(--panel-bg);
padding: 20px;
border-radius: 4px;
border: 1px solid var(--border-color);
width: 604px;
box-shadow: 0 0 25px var(--shadow-color);
max-height: calc(100vh - 20px);
overflow-y: auto;
scrollbar-width: thin;
scrollbar-color: var(--accent-color) var(--main-bg);
}
#controls::-webkit-scrollbar {
width: 8px;
}
#controls::-webkit-scrollbar-track {
background: var(--main-bg);
}
#controls::-webkit-scrollbar-thumb {
background-color: var(--accent-color);
border-radius: 4px;
border: 2px solid var(--main-bg);
}
h2 {
color: var(--accent-hover);
text-align: center;
margin-top: 0;
margin-bottom: 20px;
font-size: 2em;
letter-spacing: 4px;
text-shadow: 0 0 10px var(--shadow-color);
font-weight: 300;
}
h3 {
margin: 20px 0 10px 0;
color: var(--text-color);
font-size: 0.9em;
text-transform: uppercase;
letter-spacing: 1px;
border-bottom: 1px solid var(--border-color);
padding-bottom: 8px;
font-weight: 300;
}
h4 {
margin: 10px 0 5px 0;
color: var(--text-color);
font-weight: 400;
opacity: 0.7;
}
.row {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
gap: 10px;
}
.lfo-rate-control {
display: flex;
width: 100%;
align-items: center;
gap: 10px;
}
.lfo-rate-control button {
flex-grow: 0;
padding: 8px 10px;
min-width: 60px;
}
label {
font-size: 0.9em;
white-space: nowrap;
margin-right: 10px;
}
button {
background: #333;
color: var(--text-color);
border: 1px solid #555;
padding: 10px 15px;
cursor: pointer;
border-radius: 3px;
font-weight: 700;
font-family: 'Doto';
font-size: 1.25em;
text-transform: uppercase;
flex-grow: 1;
transition: all 0.2s ease;
}
button:disabled {
cursor: not-allowed;
opacity: 0.5;
}
button:hover:not(:disabled) {
background: #444;
border-color: var(--accent-hover);
color: #fff;
}
button.active,
#playButton.active {
background: var(--accent-color);
color: var(--main-bg);
border-color: var(--accent-hover);
box-shadow: 0 0 10px var(--shadow-color);
font-weight: 900;
}
#lyraContainer button.active {
background: var(--lyra-color);
color: var(--main-bg);
border-color: var(--lyra-color);
box-shadow: 0 0 10px var(--lyra-shadow);
}
#randomizeButton {
margin-left: 10px;
}
input[type="range"] {
width: 100%;
accent-color: var(--accent-color);
}
input[type="text"] {
background: #222;
color: var(--text-color);
border: 1px solid #555;
padding: 8px;
border-radius: 3px;
font-family: inherit;
width: 100%;
}
select {
background: #222;
color: var(--text-color);
border: 1px solid #555;
padding: 8px;
border-radius: 3px;
font-family: inherit;
width: 100%;
}
details,
.control-group,
#presetManager {
background: rgba(0, 0, 0, 0.2);
border: 1px solid var(--border-color);
padding: 15px;
margin-top: 20px;
border-radius: 4px;
}
details {
margin-top: 0;
padding-top: 0;
padding-bottom: 5px;
}
/* NEW: Shared style for summary elements */
details>summary {
cursor: pointer;
margin: 20px 0 10px 0;
color: var(--text-color);
font-size: 0.9em;
text-transform: uppercase;
letter-spacing: 1px;
border-bottom: 1px solid var(--border-color);
padding-bottom: 8px;
font-weight: 300;
outline: none;
list-style: none;
/* Hide default triangle */
position: relative;
}
details>summary::-webkit-details-marker {
display: none;
/* Hide default triangle in Chrome */
}
details>summary::after {
content: '+';
position: absolute;
right: 0;
top: 50%;
transform: translateY(-50%) rotate(0deg);
font-weight: bold;
transition: transform 0.2s ease-in-out;
}
details[open]>summary::after {
transform: translateY(-50%) rotate(45deg);
}
details[open]>summary {
margin-bottom: 15px;
}
#presetManager h3 {
margin-top: 0;
text-align: center;
border-bottom-color: var(--accent-color);
}
#patchBay {
background: rgba(0, 0, 0, 0.2);
border: 1px solid var(--border-color);
padding: 15px;
margin-top: 20px;
border-radius: 4px;
}
#patchBay h3 {
margin-top: 0;
text-align: center;
border-bottom: 2px solid var(--accent-color);
}
.patch-creator .row {
margin-bottom: 8px;
}
.patch-creator label {
flex-basis: 30%;
text-align: right;
}
.patch-creator select {
flex-basis: 65%;
}
#addPatchButton {
width: 100%;
margin-top: 15px;
background-color: var(--accent-color);
font-weight: 900;
color: var(--main-bg);
}
#activePatches {
margin-top: 20px;
}
.patch-instance {
background: #2a2a2e;
border-left: 4px solid var(--accent-color);
padding: 8px 12px;
margin-bottom: 8px;
border-radius: 3px;
display: flex;
justify-content: space-between;
align-items: center;
font-size: 0.85em;
}
.patch-instance {
cursor: pointer;
transition: background-color 0.2s ease-in-out;
}
.patch-instance:hover {
background-color: #38383e;
}
.patch-instance button {
background: #502200;
color: var(--accent-hover);
border: 1px solid #804400;
padding: 3px 8px;
font-size: 0.8em;
flex-grow: 0;
}
.patch-instance span {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 300px;
display: inline-block;
}
.pattern-row {
display: flex;
align-items: center;
gap: 5px;
margin-bottom: 5px;
}
.pattern-label {
width: 40px;
text-align: right;
margin-right: 10px;
font-size: 0.9em;
font-weight: bold;
color: var(--accent-hover);
cursor: pointer;
user-select: none;
padding: 5px 0;
border-radius: 3px;
}
.pattern-label:hover {
background-color: #333;
}
.pattern-row[data-type="lyraGate"] .pattern-label {
color: var(--lyra-color);
}
.steps-container {
display: flex;
flex-wrap: nowrap;
/* Ensures single line by default */
overflow-x: auto;
/* Allows horizontal scrolling */
gap: 3px;
flex-grow: 1;
background: rgba(0, 0, 0, 0.2);
padding: 5px;
border-radius: 3px;
}
.steps-container::-webkit-scrollbar {
height: 4px;
}
.steps-container::-webkit-scrollbar-thumb {
background: #555;
}
.steps-container::-webkit-scrollbar-track {
background: rgba(0, 0, 0, 0.2);
}
.pattern-row .step {
width: 14px;
height: 14px;
border: 1px solid #444;
background: #222;
cursor: pointer;
flex-shrink: 0;
/* Prevents steps from shrinking */
border-radius: 2px;
transition: background-color 0.1s, opacity 0.1s, border-color 0.1s;
position: relative;
display: flex;
align-items: center;
justify-content: center;
color: white;
font-size: 9px;
font-weight: bold;
text-shadow: 0 0 2px black;
box-sizing: border-box;
/* Important for border display */
}
.pattern-row .step.active {
background-color: var(--accent-color);
}
.pattern-row[data-type="lyraGate"] .step.active {
background-color: var(--lyra-color);
}
/* BASS ACCENT FOR OCTAVE DROPS */
.pattern-row[data-type="bass"] .step.active.accent {
border-bottom: 2px solid #ff4136;
}
/* --- TRIG CONDITION STYLES --- */
@keyframes flash-inactive {
0%,
100% {
background-color: var(--accent-color);
opacity: 0.3;
}
50% {
background-color: #222;
}
}
/* --- PROBABILITY STYLES --- */
.step.active.has-prob {
box-shadow: inset var(--lyra-color) -2px -2px 0, inset var(--lyra-color) 2px 2px 0;
}
.pattern-row[data-type="lyraGate"] .step.active.has-prob {
box-shadow: inset var(--accent-color) -2px -2px 0, inset var(--accent-color) 2px 2px 0;
}
/* Ensure the playhead indicator is always solid and visible */
.pattern-row .step.current {
box-shadow: 0 0 8px #fff;
border-color: #fff !important;
/* Overrides prob and trig borders */
border-style: solid !important;
/* Overrides prob dashed style */
}
.step.active.trig-first {
border-top: 2px solid white;
line-height: 10px;
/* Adjust if text is misaligned */
}
.step.active.trig-second {
border-bottom: 2px solid white;
}
.pattern-row[data-type="lyraGate"] .step.active.trig-first {
border-top: 2px solid #aadeff;
}
.pattern-row[data-type="lyraGate"] .step.active.trig-second {
border-bottom: 2px solid #aadeff;
}
/* A step with a condition that is currently inactive */
.step.active.flashing-inactive {
/* The animation will override the background-color from .active */
animation: flash-inactive 1s infinite;
}
/* --- END TRIG CONDITION STYLES --- */
#kaosPad {
position: fixed;
right: 20px;
top: 20px;
width: 200px;
height: 200px;
background: linear-gradient(to top right, rgba(255, 140, 0, 0.1), rgba(0, 0, 0, 0.3));
border: 2px solid var(--accent-color);
border-radius: 4px;
box-shadow: 0 0 20px var(--shadow-color);
cursor: crosshair;
z-index: 100;
touch-action: none;
}
#kaosPuck {
position: absolute;
width: 12px;
height: 12px;
background: var(--accent-hover);
border-radius: 50%;
border: 1px solid var(--main-bg);
transform: translate(-50%, -50%);
pointer-events: none;
top: 50%;
left: 50%;
}
@media (max-width: 840px) {
#kaosPad {
width: 60px;
height: 60px;
}
#kaosPuck {
width: 6px;
height: 6px;
}
}
#lyraContainer h3 {
color: var(--lyra-color);
border-color: var(--lyra-color);
cursor: pointer;
position: relative;
user-select: none;
transition: margin-bottom 0.3s ease-in-out;
}
#lyraContainer h3::after {
content: '+';
position: absolute;
right: 10px;
top: 50%;
transform: translateY(-50%);
font-weight: bold;
font-size: 1.5em;
transition: transform 0.3s ease-in-out;
}
#lyraContainer.open h3::after {
transform: translateY(-50%) rotate(45deg);
}
#lyraContainer.open h3 {
margin-bottom: 0;
}
#lyraContainer.open #lyraControlsWrapper {
max-height: 2000px;
transition: max-height 0.6s ease-in;
padding-top: 15px;
}
#lyraControlsWrapper {
max-height: 0;
overflow: hidden;
transition: max-height 0.4s ease-out, padding-top 0.4s ease-out;
padding-top: 0;
}
#lyraContainer .control-group {
border-left: 3px solid #005577;
padding-left: 10px;
margin-bottom: 15px;
background: none;
border-width: 0 0 0 3px;
padding: 0 0 0 10px;
margin-top: 0;
}
/* Sequencer Modules randomize buttons */
.control-group h4>button {
display: inline-block;
margin-left: 10px;
margin-bottom: 10px;
font-size: 12px;
padding: 5px 10px;
background: var(--accent-color);
color: var(--main-bg);
}
#lyraContainer input[type="range"] {
accent-color: var(--lyra-color);
}
#lyraContainer select {
accent-color: var(--lyra-color);
background: #2a2a2e;
border-color: #005577;
}
#lyraLooperControls button.recording {
background-color: #ff4136;
box-shadow: 0 0 10px #ff4136;
color: white;
}
#lyraLooperControls button.playing {
background-color: var(--lyra-color);
color: var(--main-bg);
box-shadow: 0 0 10px var(--lyra-color);
}
button#lyraSeqToggle.active {
font-weight: 900;
}
#lyraWipeButton {
background: #500000;
color: #ffaaaa;
border-color: #800000;
flex-grow: 0.5;
}
#lyraKeyDisplay {
margin-top: 15px;
padding: 10px;
background: rgba(0, 0, 0, 0.3);
border-radius: 4px;
text-align: center;
color: var(--lyra-color);
font-size: 1.2em;
letter-spacing: 2px;
}
.fx-panel {
padding: 10px 0;
border-top: 1px dashed #005577;
margin-top: 10px;
}
.lyra-step {
height: 35px;
background: #2a2a2e;
border: 2px solid #444;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
font-size: 0.8em;
font-weight: bold;
transition: all 0.1s;
position: relative;
user-select: none;
}
.lyra-step:hover {
background: #3a3a3e;
border-color: var(--lyra-color);
}
.lyra-step.active {
background: var(--lyra-color);
color: var(--main-bg);
border-color: #0088cc;
}
.lyra-step.slide {
border-left: 4px solid #ff8c00;
}
.lyra-step.accent {
border-top: 3px solid #ff4136;
}
.lyra-step.current {
box-shadow: 0 0 10px #fff;
border: 2px solid #fff;
}
.lyra-step.active.slide {
border-left: 4px solid #ffff00;
}
.lyra-step.active.accent {
border-top: 3px solid #ff6666;
}
.modal-overlay {
display: none;
position: fixed;
z-index: 1000;
left: 0;
top: 0;
width: 100%;
height: 100%;
overflow: auto;
background-color: rgba(0, 0, 0, 0.7);
justify-content: center;
align-items: center;
}
.modal-content {
background-color: #22252a;
margin: auto;
padding: 20px;
border: 1px solid var(--accent-color);
border-radius: 5px;
width: 80%;
max-width: 600px;
box-shadow: 0 5px 15px rgba(0, 0, 0, 0.5);
display: flex;
flex-direction: column;
}
.modal-content h4 {
margin-top: 0;
color: var(--accent-hover);
text-align: center;
}
.modal-content textarea {
width: 100%;
height: 300px;
background: #111;
color: var(--text-color);
border: 1px solid #444;
font-family: 'Roboto Mono', monospace;
font-size: 12px;
resize: vertical;
margin-bottom: 15px;
box-sizing: border-box;
}
.modal-buttons {
display: flex;
gap: 10px;
justify-content: flex-end;
}
/* Style the value readouts in the modal */
#patchEditModal span {
font-family: 'Roboto Mono', monospace;
color: var(--accent-hover);
font-size: 0.9em;
}
#patchEditModal .row {
align-items: center;
/* Better vertical alignment for sliders and labels */
}
.context-menu hr {
border: none;
height: 1px;
background-color: var(--border-color);
margin: 5px 0;
}
/* Adaptive layout for the sequencer */
@media (max-width: 650px) {
#controls {
padding: 10px;
width: 98vw;
left: 1vw;
box-sizing: border-box;
}
#controls button {
font-size: 9px;
padding: 9px 3px;
}
/* Switch to a 16x2 grid on smaller screens */
.steps-container {
display: grid;
grid-template-columns: repeat(16, 1fr);
gap: 4px;
overflow-x: hidden;
/* Hide scrollbar in grid mode */
}
h3 span { display: none; } /* hide keyboard tips */
.pattern-row .step {
width: auto;
/* Allow steps to fill grid cells */
height: 15px;
/* Slightly larger for touch */
}
input#lyraPortamento {
max-width: 80px;
}
label[for=lyraPortamento] {
font-size: 8px;
}
}
/* Styles for the Synth Step Editor Modal */
#synthStepEditModal .row {
align-items: center;
margin-bottom: 18px;
}
#synthStepEditModal label {
flex-basis: 25%;
text-align: right;
margin-right: 15px;
color: var(--text-color);
opacity: 0.8;
}
#synthStepEditModal select,
#synthStepEditModal input[type="range"] {
flex-grow: 1;
}
#synthStepEditModal .step-toggles {
justify-content: space-around;
margin-top: 25px;
}
#synthStepEditModal .toggle-wrapper {
display: flex;
align-items: center;
gap: 8px;
}
/* Visually appealing checkboxes */
#synthStepEditModal .styled-checkbox {
appearance: none;
-webkit-appearance: none;
width: 18px;
height: 18px;
border: 2px solid var(--border-color);
border-radius: 3px;
background-color: #333;
cursor: pointer;
position: relative;
transition: background-color 0.2s, border-color 0.2s;
}
#synthStepEditModal .styled-checkbox:hover {
border-color: var(--lyra-color);
}
#synthStepEditModal .styled-checkbox:checked {
background-color: var(--lyra-color);
border-color: var(--lyra-color);
}
#synthStepEditModal .styled-checkbox:checked::after {
content: '✔';
font-size: 14px;
color: var(--main-bg);
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
}
.context-menu {
position: fixed;
background: #28282e;
border: 1px solid var(--border-color);
z-index: 2000;
padding: 5px 0;
border-radius: 4px;
box-shadow: 0 5px 15px rgba(0, 0, 0, 0.3);
font-size: 0.9em;
min-width: 200px;
color: var(--text-color);
}
.context-menu-item {
padding: 8px 15px;
cursor: pointer;
user-select: none;
/* Prevents text selection on click */
}
.context-menu-item:hover {
background-color: var(--accent-color);
}
.context-menu-item.disabled {
cursor: not-allowed;
color: #666;
}
.context-menu-item.disabled:hover {
background-color: transparent;
}
.context-menu-separator {
border: 0;
height: 1px;
background-color: var(--border-color);
margin: 5px 0;
}
.context-menu-back-button {
padding: 8px 15px;
cursor: pointer;
font-weight: bold;
color: var(--accent-color);
border-bottom: 1px solid var(--border-color);
margin: -5px 0 5px 0;
background-color: #333;
}
</style>
</head>
<body>
<div id="canvas-container"></div>
<div id="kaosPad">
<div id="kaosPuck"></div>
</div>
<div id="controls">
<!-- <h2>WHITE DWARF</h2> -->
<pre style="display: block; margin: 0 auto; padding: 0; font-size: clamp(2px, 1.125vw, 8.25px); color: rgba(255, 255, 255, 0.85); color: var(--accent-hover);">
_| _| _| _| _|_|
_| _| _| _|_|_| _|_|_|_| _|_| _|_|_| _| _| _| _|_|_| _| _|_| _|
_| _| _| _| _| _| _| _|_|_|_| _| _| _| _| _| _| _| _|_| _|_|_|_|
_| _| _| _| _| _| _| _| _| _| _| _| _| _| _| _| _| _| _|
_| _| _| _| _| _|_| _|_|_| _|_|_| _| _| _|_|_| _| _|
</pre>
<canvas id="masterViz" width="600" height="1" style="margin-top:15px; background:#000; max-width: 100%;"></canvas>
<br><br>
<div class="row">
<button id="playButton">PLAY ▶</button>
<button id="randomizeButton">SHUFFLE SEQUENCER</button>
<button id="randomizeLyraSettingsButton">RANDOMIZE SYNTH</button>
</div>
<div class="row"><label for="masterVolume">VOLUME:</label><input type="range" id="masterVolume" min="0" max="1" step="0.01" value="0.4"></div>
<div class="row"><label for="bpm">BPM:</label><input type="range" id="bpm" min="60" max="190" value="138"><span id="bpmValue" style="min-width: 30px;">138</span></div>
<h3>32-STEP SEQUENCER <span style="font-size:0.8em; opacity: 0.7; float:right; margin-top:2px;">Shift+Click for Trig Conditions | Ctrl+Click for Bass Accent</span></h3>
<div class="pattern-row" data-type="kick"><span class="pattern-label">BD:</span>
<div class="steps-container"></div>
</div>
<div class="pattern-row" data-type="snare"><span class="pattern-label">SD:</span>
<div class="steps-container"></div>
</div>
<div class="pattern-row" data-type="hat"><span class="pattern-label">HHT:</span>
<div class="steps-container"></div>
</div>
<div class="pattern-row" data-type="bass"><span class="pattern-label">BASS:</span>
<div class="steps-container"></div>
</div>
<div class="pattern-row" data-type="lyraGate"><span class="pattern-label">SYNTH:</span>
<div class="steps-container"></div>
</div>
<br>
<details>
<summary>MIXER</summary>
<div style="padding-top: 5px;">
<div class="row">
<label for="kickVolume">Kick Volume:</label>
<input type="range" id="kickVolume" min="0" max="1" step="0.01" value="1">
</div>
<div class="row">
<label for="snareVolume">Snare Volume:</label>
<input type="range" id="snareVolume" min="0" max="1" step="0.01" value="0.8">
</div>
<div class="row">
<label for="hatVolume">Hat Volume:</label>
<input type="range" id="hatVolume" min="0" max="1" step="0.01" value="0.8">
</div>
<div class="row">
<label for="bassVolume">Bass Volume:</label>
<input type="range" id="bassVolume" min="0" max="1" step="0.01" value="0.8">
</div>
<div class="row">
<label for="synthVolume">Synth Volume:</label>
<input type="range" id="synthVolume" min="0" max="1" step="0.01" value="0.7">
</div>
</div>
</details>
<br>
<details>
<summary>SEQUENCER MACROS</summary>
<h5>PATTERN GENERATORS</h5>
<div class="row">
<button onclick="(function(){
window.kickMode=(window.kickMode||0)+1;
var mode=window.kickMode%13;
var s=app.audioSystem.sequences.kick;
for(var i=0;i<s.length;i++){
if(mode===0){s[i]=(i%4===0)}
else if(mode===1){s[i]=(i%8===0)}
else if(mode===2){s[i]=(i===0||i===20)}
else if(mode===3){s[i]=(i===0)}
else if(mode===4){s[i]=(i===0||i===6)}
else if(mode===5){s[i]=(i===0||i===10)}
else if(mode===6){s[i]=(i===0||i===8||i===14)}
else if(mode===7){s[i]=(i%16===0)}
else if(mode===8){s[i]=(i===0||i===5||i===10||i===16||i===21||i===26)}
else if(mode===9){s[i]=(i===0||i===6||i===10||i===16||i===22||i===26)}
else if(mode===10){s[i]=(i%8===0)}
else if(mode===11){s[i]=(i===0||i===7||i===12||i===19||i===24)}
else if(mode===12){s[i]=(i%16===0||i%16===2)}
}
app.audioSystem.updatePatternEditor();
})()">KICKCYC</button>
<button onclick="(function(){
window.sdMode = (window.sdMode || 0) + 1
const patterns = [
[4, 12, 20, 28],
[5, 13, 21, 29],
[2, 6, 10, 14, 18, 22, 26, 30],
[4, 5, 12, 13, 20, 21, 28, 29],
[12, 28],
[4, 5, 20, 21],
[3, 4, 5, 11, 12, 13],
[5, 10, 21, 26],
[6, 14, 22, 30],
[7, 15, 23, 31],
[4, 10, 12, 20, 26, 28]
]
const mode = window.sdMode % patterns.length
const s = app.audioSystem.sequences.snare
for (let i = 0; i < s.length; i++){
s[i] = patterns[mode].includes(i)
}
app.audioSystem.updatePatternEditor()
})()">SNARECYC</button>
<button onclick="(function(){
window.hatMode=(window.hatMode||0)+1;
if(window.hatMode>3){window.hatMode=0}
var s=app.audioSystem.sequences.hat;
for(var i=0;i<s.length;i++){
if(window.hatMode===0){s[i]=(i%2===0)}
else if(window.hatMode===1){s[i]=(i%2===1)}
else if(window.hatMode===2){s[i]=(Math.random()>0.5)}
else if(window.hatMode===3){s[i]=(i%4===2)}
}
app.audioSystem.updatePatternEditor();
})()">HATCYC</button>
<button onclick="window.bassClicks=(window.bassClicks||0)+1,(function(){var s=app.audioSystem.sequences.bass;for(var i=0;i<s.length;i++){s[i]=window.bassClicks%3===0||Math.random()>0.5}})(),app.audioSystem.updatePatternEditor()">BASSLINE</button>
<button onclick="(function(){
function euclidean(pulses, steps){
var pattern = new Array(steps).fill(false);
if (pulses === 0) return pattern;
var bucket = 0;
for(var i = 0; i < steps; i++){
bucket += pulses;
if(bucket >= steps){
pattern[i] = true;
bucket -= steps;
}
}
return pattern;
}
var seqs = app.audioSystem.sequences;
const totalSteps = 32;
seqs.snare.fill(false);
seqs.snare[4] = true;
seqs.snare[12] = true;
seqs.snare[20] = true;
seqs.snare[28] = true;
const hatPulses = Math.floor(Math.random() * 10) + 7;
seqs.hat = euclidean(hatPulses, totalSteps);
for(let i = 0; i < totalSteps; i++){
if(seqs.snare[i]){
seqs.hat[i] = false;
}
}
app.audioSystem.stepMeta.snare = {};
app.audioSystem.stepMeta.hat = {};
app.audioSystem.updatePatternEditor();
})()">EUCLID</button>
</div>
<h5>RHYTHM TRANSFORMS</h5>
<div class="row">
<button onclick="['kick','snare','hat','bass','lyraGate'].forEach(function(t){var s=app.audioSystem.sequences[t];app.audioSystem.sequences[t]=s.map(function(v){return!v})});app.audioSystem.updatePatternEditor()">INVERT</button>
<button onclick="['kick','snare','hat','bass','lyraGate'].forEach(function(t){app.audioSystem.sequences[t].reverse()}),app.audioSystem.updatePatternEditor()">REVERSE</button>
<button onclick="['kick','snare','hat','bass'].forEach(function(t){for(var i=0;i<32;i++){app.audioSystem.sequences[t][i]=Math.random()<0.5}});app.audioSystem.updatePatternEditor()">RAND50</button>
<button onclick="(function(){
window.velClick=(window.velClick||0)+1;
var mode=Math.floor((window.velClick-1)/2)%3;
['kick','snare','hat','bass'].forEach(function(t){
var s=app.audioSystem,sequences=s.sequences[t],meta=s.stepMeta[t];
for(var i=0;i<sequences.length;i++){
if(sequences[i]){
meta[i]=meta[i]||{velocity:1,prob:1,ratchet:1};
if(mode===0){ meta[i].velocity=(i%8===0)?1:(0.4+Math.random()*0.4) }
else if(mode===1){ meta[i].velocity=(i%8===0)?1:(0.3+Math.random()*0.7) }
else if(mode===2){ meta[i].velocity=1 }
}
}
});
app.audioSystem.updatePatternEditor();
})()">HUMANIZE</button>
<button onclick="(function(){
var s=app.audioSystem.sequences.kick
var m=app.audioSystem.stepMeta.kick
for(var i=0;i<s.length;i++){
if(s[i]){
m[i]=m[i]||{}
m[i].ratchet=2
}
}
app.audioSystem.updatePatternEditor()
})()">BOUNCE</button>
</div>
<h5>KICK PATTERNS</h5>
<div class="row">
<button onclick="(function(){
var s=app.audioSystem.sequences.kick;
var m=app.audioSystem.stepMeta.kick={};
s.forEach((v,i)=>s[i]=i%8===0);
app.audioSystem.updatePatternEditor();
})()">STAMP</button>
<button onclick="(function(){
var s=app.audioSystem.sequences.kick;
var m=app.audioSystem.stepMeta.kick;
for(var i=0;i<s.length;i++){
if(!s[i] && i%8===4){
s[i]=true
m[i]={velocity:0.4,prob:1,ratchet:1}
}
}
app.audioSystem.updatePatternEditor()
})()">GHOST</button>
<button onclick="(function(){
var s=app.audioSystem.sequences.kick;
s.forEach((v,i)=>s[i]=Math.random()<0.3);
app.audioSystem.stepMeta.kick={};
app.audioSystem.updatePatternEditor();
})()">SHUFFLE</button>
<button onclick="(function(){
var s=app.audioSystem.sequences.kick;
for(var i=0;i<s.length;i++){
s[i]=(i%16===0||i%16===2)
}
app.audioSystem.stepMeta.kick={};
app.audioSystem.updatePatternEditor();
})()">HEART</button>
<button onclick="(function(){
app.audioSystem.sequences.kick.fill(false);
app.audioSystem.stepMeta.kick={};
app.audioSystem.updatePatternEditor();
})()">KCLR</button>
</div>
<h5>SNARE PATTERNS</h5>
<div class="row">
<button onclick="(function(){
var s=app.audioSystem.sequences.snare;
s.forEach((v,i)=>s[i]=[4,12,20,28].includes(i));
app.audioSystem.stepMeta.snare={};
app.audioSystem.updatePatternEditor();
})()">BACKBEAT</button>
<button onclick="(function(){
var s=app.audioSystem.sequences.snare;
s.forEach((v,i)=>s[i]=i%4===2);
app.audioSystem.stepMeta.snare={};
app.audioSystem.updatePatternEditor();
})()">OFFBEAT</button>
<button onclick="(function(){
var s=app.audioSystem.sequences.snare;
for(var i=0;i<s.length;i++){
s[i]=false;
}
s[8]=true;
s[24]=true;
app.audioSystem.updatePatternEditor();
})()">HALFTIME</button>
<button onclick="(function(){
var s=app.audioSystem.sequences.snare;
for(var i=0;i<s.length;i++){
s[i]=false;
}
for(var j=8;j<16;j++){
s[j]=true;
}
app.audioSystem.updatePatternEditor();
})()">ROLL</button>
<button onclick="(function(){
app.audioSystem.sequences.snare.fill(false);
app.audioSystem.stepMeta.snare={};
app.audioSystem.updatePatternEditor();
})()">SCLR</button>
</div>
<h5>HI-HAT EFFECTS</h5>
<div class="row">
<button onclick="(function(){
var seq=app.audioSystem.sequences.hat
var meta=app.audioSystem.stepMeta.hat
for(var i=0;i<seq.length;i++){
seq[i]=i%3===0
delete meta[i]
}
app.audioSystem.updatePatternEditor()
})()">TRIPLET</button>
<button onclick="(function(){
var seq=app.audioSystem.sequences.hat
var meta=app.audioSystem.stepMeta.hat
for(var i=0;i<seq.length;i++){
if(seq[i]){
meta[i]=meta[i]||{}
meta[i].ratchet=[2,3,4][Math.floor(Math.random()*3)]
}
}
app.audioSystem.updatePatternEditor()
})()">RATCHET</button>
<button onclick="(function(){
var seq=app.audioSystem.sequences.hat
var meta=app.audioSystem.stepMeta.hat
for(var i=0;i<seq.length;i++){
if(seq[i]){
meta[i]=meta[i]||{}
meta[i].velocity=1-(i/(seq.length-1))
}
}
app.audioSystem.updatePatternEditor()
})()">FADE</button>
<button onclick="(function(){
var seq=app.audioSystem.sequences.hat
var meta=app.audioSystem.stepMeta.hat
for(var i=0;i<seq.length;i++){
if(seq[i]){
meta[i]=meta[i]||{}
meta[i].prob=Math.random()
}
}
app.audioSystem.updatePatternEditor()
})()">PROB</button>
<button onclick="(function(){
app.audioSystem.sequences.hat.fill(false);
app.audioSystem.stepMeta.hat={};
app.audioSystem.updatePatternEditor()
})()">HCLR</button>
</div>
<h5>BASS PATTERNS</h5>
<div class="row">
<button onclick="(function(){
var s=app.audioSystem.sequences.bass;
s.forEach((v,i)=>s[i]=i%4===0);
app.audioSystem.stepMeta.bass={};
app.audioSystem.updatePatternEditor();
})()">PULSE</button>
<button onclick="(function(){
var s=app.audioSystem.sequences.bass;
s.forEach((v,i)=>s[i]=i%3===0);
app.audioSystem.stepMeta.bass={};
app.audioSystem.updatePatternEditor();
})()">STRIDE</button>
<button onclick="(function(){
var b=app.audioSystem.sequences.bass
var p=[0,4,7,12,16,20,23,28]
for(var i=0;i<b.length;i++){
b[i]=p.indexOf(i)!==-1
}
app.audioSystem.updatePatternEditor()
})()">FUNK</button>
<button onclick="(function(){
var s=app.audioSystem.sequences.bass;
var m=app.audioSystem.stepMeta.bass={};
s.forEach((v,i)=>{ if(v){ m[i]={slide:true}; }});
app.audioSystem.updatePatternEditor();
})()">GLIDE</button>
<button onclick="(function(){
app.audioSystem.sequences.bass.fill(false);
app.audioSystem.stepMeta.bass={};
app.audioSystem.updatePatternEditor();
})()">BCLR</button>
</div>
<h5>PERFORMANCE TOOLS</h5>
<div class="row">
<button onclick="window.gateClicks=(window.gateClicks||0)+1,(function(){var s=app.audioSystem.sequences.lyraGate;for(var i=0;i<s.length;i++){s[i]=window.gateClicks%3===0||Math.random()>0.5}})(),app.audioSystem.updatePatternEditor()">GATE</button>
<button onclick="(function(){
var b=app.audioSystem.sequences.bass
var m=app.audioSystem.stepMeta.bass
for(var i=0;i<b.length;i++){
if(b[i]){
m[i]=m[i]||{}
m[i].velocity=Math.random()
}
}
app.audioSystem.updatePatternEditor()
})()">THROB</button>
<button onclick="(function(){
var s=app.audioSystem.sequences.snare
for(var i=0;i<s.length;i++){
s[i]=i%4===0
}
app.audioSystem.stepMeta.snare={}
app.audioSystem.updatePatternEditor()
})()">DROP</button>
<button onclick="(function(){
var b=app.audioSystem.sequences.bass
var m=app.audioSystem.stepMeta.bass
for(var i=0;i<b.length;i++){
if(b[i]){
m[i]=m[i]||{}
m[i].slide=!m[i].slide
}
}
app.audioSystem.updatePatternEditor()
})()">SLIDE</button>
<button onclick="(function(){
['kick','snare','hat','bass','lyraGate'].forEach(function(t){
app.audioSystem.sequences[t].fill(false);
app.audioSystem.stepMeta[t]={};
});
app.audioSystem.updatePatternEditor();
})()">RESET</button>
</div>
<h5>COMBO EFFECTS</h5>
<div class="row">
<button onclick="(function(){
// Progressive buildup over time - adds complexity each cycle
window.buildupStep = (window.buildupStep || 0) + 1;
var phase = window.buildupStep % 4;
var seqs = app.audioSystem.sequences;
var meta = app.audioSystem.stepMeta;
if(phase === 0) {
// Start with just kick
seqs.kick.forEach((v,i) => seqs.kick[i] = i%8===0);
seqs.snare.fill(false);
seqs.hat.fill(false);
seqs.bass.fill(false);
} else if(phase === 1) {
// Add snare
seqs.snare.forEach((v,i) => seqs.snare[i] = [4,12,20,28].includes(i));
} else if(phase === 2) {
// Add hats
seqs.hat.forEach((v,i) => seqs.hat[i] = i%2===0);
} else if(phase === 3) {
// Add bass and complexity
seqs.bass.forEach((v,i) => seqs.bass[i] = i%4===0);
// Add some ratchets for energy
for(var i=0;i<32;i++){
if(seqs.hat[i] && Math.random()<0.3){
meta.hat[i] = meta.hat[i] || {};
meta.hat[i].ratchet = 2;
}
}
}
app.audioSystem.updatePatternEditor();
})()">BUILDUP</button>
<button onclick="(function(){
// Progressive breakdown - removes elements
window.breakStep = (window.breakStep || 0) + 1;
var phase = window.breakStep % 4;
var seqs = app.audioSystem.sequences;
if(phase === 0) {
// Remove bass first
seqs.bass.fill(false);
app.audioSystem.stepMeta.bass = {};
} else if(phase === 1) {
// Remove most hats
seqs.hat.forEach((v,i) => seqs.hat[i] = i%8===0);
app.audioSystem.stepMeta.hat = {};
} else if(phase === 2) {
// Simplify snare to halftime
seqs.snare.fill(false);
seqs.snare[8] = true;
seqs.snare[24] = true;
} else if(phase === 3) {
// Just kick on 1
seqs.kick.fill(false);
seqs.kick[0] = true;
seqs.snare.fill(false);
seqs.hat.fill(false);
}
app.audioSystem.updatePatternEditor();
})()">BREAKDOWN</button>
<button onclick="(function(){
// Add tension through probability and ratchets
var seqs = app.audioSystem.sequences;
var meta = app.audioSystem.stepMeta;
['kick','snare','hat','bass'].forEach(function(track){
for(var i=0;i<32;i++){
if(seqs[track][i]){
meta[track][i] = meta[track][i] || {};
// Random probability between 0.6-0.9 for uncertainty
meta[track][i].prob = 0.6 + Math.random() * 0.3;
// Occasional ratchets for chaos
if(Math.random() < 0.2){
meta[track][i].ratchet = [2,3][Math.floor(Math.random()*2)];
}
}
}
});
app.audioSystem.updatePatternEditor();
})()">TENSION</button>
<button onclick="(function(){
// Clean, satisfying resolution
var seqs = app.audioSystem.sequences;
// Simple, solid patterns
seqs.kick.forEach((v,i) => seqs.kick[i] = i%4===0);
seqs.snare.forEach((v,i) => seqs.snare[i] = [4,12,20,28].includes(i));
seqs.hat.forEach((v,i) => seqs.hat[i] = i%2===0);
seqs.bass.forEach((v,i) => seqs.bass[i] = i%8===0);
// Clear all metadata for clean sound
['kick','snare','hat','bass'].forEach(function(track){
app.audioSystem.stepMeta[track] = {};
});
app.audioSystem.updatePatternEditor();
})()">RELEASE</button>
<button onclick="(function(){
// Chaotic glitch effects
var seqs = app.audioSystem.sequences;
var meta = app.audioSystem.stepMeta;
['kick','snare','hat','bass'].forEach(function(track){
for(var i=0;i<32;i++){
if(seqs[track][i] && Math.random()<0.4){
meta[track][i] = meta[track][i] || {};
// Micro-stutters
meta[track][i].ratchet = [3,4,5][Math.floor(Math.random()*3)];
// Velocity cuts
meta[track][i].velocity = Math.random() < 0.3 ? 0.2 : 1;
// Some drops out completely
if(Math.random() < 0.1) meta[track][i].prob = 0;
}
}
});
app.audioSystem.updatePatternEditor();
})()">GLITCH</button>
</div>
<h5>TIMING EFFECTS</h5>
<div class="row">
<button onclick="(function(){
// Toggle swing amount
var swingSlider = document.getElementById('swing');
var currentSwing = parseFloat(swingSlider.value);
var newSwing = currentSwing === 0 ? 0.33 : (currentSwing < 0.5 ? 0.66 : 0);
swingSlider.value = newSwing;
swingSlider.dispatchEvent(new Event('input'));
})()">SWING</button>
<button onclick="(function(){
// Slightly rush the timing on random hits via velocity/ratchet
var meta = app.audioSystem.stepMeta;
['kick','snare','hat'].forEach(function(track){
var seq = app.audioSystem.sequences[track];
for(var i=0;i<32;i++){
if(seq[i] && Math.random()<0.3){
meta[track][i] = meta[track][i] || {};
meta[track][i].ratchet = 2; // Quick double hit feels rushed
meta[track][i].velocity = 0.8;
}
}
});
app.audioSystem.updatePatternEditor();
})()">RUSH</button>
<button onclick="(function(){
// Create dragging feel with probability drops
var meta = app.audioSystem.stepMeta;
['kick','snare','hat'].forEach(function(track){
var seq = app.audioSystem.sequences[track];
for(var i=0;i<32;i++){
if(seq[i] && Math.random()<0.4){
meta[track][i] = meta[track][i] || {};
meta[track][i].prob = 0.7; // Some hits drop out = dragging feel
}
}
});
app.audioSystem.updatePatternEditor();
})()">DRAG</button>
<button onclick="(function(){
// Rapid fire stutters
var seqs = app.audioSystem.sequences;
var meta = app.audioSystem.stepMeta;
// Pick 2-4 random steps across all tracks
var targets = [];
for(var i=0;i<4;i++){
targets.push(Math.floor(Math.random()*32));
}
['kick','snare','hat','bass'].forEach(function(track){
targets.forEach(function(step){
if(!seqs[track][step]){
seqs[track][step] = true;
meta[track][step] = {ratchet: [4,5,6][Math.floor(Math.random()*3)], velocity: 0.8};
}
});
});
app.audioSystem.updatePatternEditor();
})()">STUTTER</button>
<button onclick="(function(){
// Reset swing and clear timing-related metadata
document.getElementById('swing').value = 0;
document.getElementById('swing').dispatchEvent(new Event('input'));
var meta = app.audioSystem.stepMeta;
['kick','snare','hat','bass'].forEach(function(track){
Object.keys(meta[track]).forEach(function(step){
if(meta[track][step].prob < 1 || meta[track][step].ratchet > 1){
delete meta[track][step];
}
});
});
app.audioSystem.updatePatternEditor();
})()">SYNC</button>
</div>
</details> <!-- END OF SEQUENCER MACROS -->
<!-- END OF PATTERN EDITOR -->
<div id="presetManager">
<h3>PRESET MANAGER</h3>
<div class="row">
<select id="presetSelect">
<option value="white dwarf"></option>
</select>
<button id="loadPresetButton">LOAD</button>
<button id="deletePresetButton" style="background-color: #500000;">DEL</button>
</div>
<div class="row">
<input type="text" id="presetNameInput" placeholder="New Preset Name...">
<button id="savePresetButton" style="background-color: #8B4000;">SAVE</button>
</div>
<div class="row" style="margin-top: 10px;">
<button id="importStateButton">IMPORT PRESET(S)</button>
<button id="exportStateButton">EXPORT CURRENT</button>
</div>
<div class="row" style="margin-top: 5px;">
<button id="exportAllButton" style="background-color: #004466; width: 100%;">EXPORT ALL PRESETS</button>
</div>
</div>
<div id="lyraContainer" class="closed">
<h3>16-STEP SYNTH</h3>
<div class="control-group">
<h4>STEP SEQUENCER</h4>
<div class="row">
<button id="lyraSeqToggle" class="active">SEQ ON</button>
<button id="lyraSeqRandomize" style="background-color: #8B4000;">ACID RIFF</button>
<button onclick="(function(){
const pulses = Math.floor(Math.random() * 8) + 3; // Random pulses from 3 to 10
const steps = 16;
const lyraSeq = app.audioSystem.lyraSequencer.steps;
let bucket = 0;
for (let i = 0; i < steps; i++) {
bucket += pulses;
if (bucket >= steps) {
lyraSeq[i].active = true;
bucket -= steps;
} else {
lyraSeq[i].active = false;
}
}
app.audioSystem.updateLyraSequencerUI();
})()">EUCLID</button>
<label for="lyraPortamento">SLIDE (ms):</label>
<input type="range" id="lyraPortamento" min="0" max="200" step="1" value="80" style="width: 100px;">
</div>
<div id="lyraStepGrid" style="display: grid; grid-template-columns: repeat(8, 1fr); gap: 3px; margin-top: 10px;">
<div class="lyra-step active accent" id="lyraStep0" data-step="0">D#</div>
<div class="lyra-step active" id="lyraStep1" data-step="1">G#</div>
<div class="lyra-step active" id="lyraStep2" data-step="2">C</div>
<div class="lyra-step active slide accent" id="lyraStep3" data-step="3">A#</div>
<div class="lyra-step" id="lyraStep4" data-step="4">—</div>
<div class="lyra-step active" id="lyraStep5" data-step="5">F</div>
<div class="lyra-step active accent" id="lyraStep6" data-step="6">D+</div>
<div class="lyra-step active" id="lyraStep7" data-step="7">D</div>
<div class="lyra-step active" id="lyraStep8" data-step="8">D#</div>
<div class="lyra-step active" id="lyraStep9" data-step="9">D+</div>
<div class="lyra-step active" id="lyraStep10" data-step="10">D#+</div>
<div class="lyra-step active" id="lyraStep11" data-step="11">F</div>
<div class="lyra-step" id="lyraStep12" data-step="12">—</div>
<div class="lyra-step active slide" id="lyraStep13" data-step="13">G</div>
<div class="lyra-step active" id="lyraStep14" data-step="14">G</div>
<div class="lyra-step" id="lyraStep15" data-step="15">—</div>
</div>
<div style="font-size: 0.7em; margin-top: 5px; color: #888;">
Click: Note On/Off | Shift+Click: Slide | Ctrl/Cmd+Click: Accent | Right-Click: Edit
</div>
</div>
<div id="lyraKeyDisplay">Play Synth: A S D F G H J K</div>
<div id="lyraControlsWrapper">
<div id="lyraLooperControls" class="row">
<button id="lyraRecButton">REC</button>
<button id="lyraPlayButton">PLAY</button>
<button id="lyraWipeButton">WIPE</button>
</div>
<div class="control-group">
<h4>OSCILLATORS</h4>
<div class="row"><label for="lyraOsc1Type">OSC1 Wave:</label><select id="lyraOsc1Type">
<option>sawtooth</option>
<option>square</option>
<option selected>sine</option>
<option>triangle</option>
</select></div>
<div class="row"><label for="lyraOsc1Oct">OSC1 Oct:</label><input type="range" id="lyraOsc1Oct" min="-2" max="2" value="1" step="1"></div>
<div class="row"><label for="lyraOsc2Type">OSC2 Wave:</label><select id="lyraOsc2Type">
<option>sawtooth</option>
<option>square</option>
<option selected>sine</option>
<option>triangle</option>
</select></div>
<div class="row"><label for="lyraOsc2Oct">OSC2 Oct:</label><input type="range" id="lyraOsc2Oct" min="-2" max="2" value="0" step="1"></div>
</div>
<div class="control-group">
<h4>MIX & MOD</h4>
<div class="row"><label for="lyraDetune">DETUNE:</label><input type="range" id="lyraDetune" min="0" max="50" value="3"></div>
<div class="row"><label for="lyraFMAmt">FM AMT:</label><input type="range" id="lyraFMAmt" min="0" max="2000" value="256"></div>
<div class="row"><label for="lyraWaveFold">WAVE FOLD:</label><input type="range" id="lyraWaveFold" min="0" max="1" step="0.01" value="0.03"></div>
<div class="row">
<label for="lyraDriveMode">DRIVE MODE:</label>
<select id="lyraDriveMode">
<option value="soft clip">Soft Clip (tanh)</option>
<option value="hard clip">Hard Clip</option>
<option value="exponential">Exponential</option>
<option value="wavefold">Wavefold</option>
</select>
</div>
<div class="row"><label for="lyraDrive">DRIVE:</label><input type="range" id="lyraDrive" min="0" max="1" step="0.01" value="0.04"></div>
</div>
<div class="control-group">
<h4>FILTER</h4>
<div class="row"><label for="lyraFilterType">TYPE:</label><select id="lyraFilterType">
<option selected value="lowpass">Low Pass</option>
<option value="highpass">High Pass</option>
<option value="bandpass">Band Pass</option>
<option value="notch">Notch</option>
</select></div>
<div class="row"><label for="lyraFilterCutoff">CUTOFF:</label><input type="range" id="lyraFilterCutoff" min="20" max="20000" value="3845"></div>
<div class="row"><label for="lyraFilterQ">RESO:</label><input type="range" id="lyraFilterQ" min="0" max="18" step="0.1" value="11.1"></div>
<div class="row"><label for="lyraFilterEnv">ENV AMT:</label><input type="range" id="lyraFilterEnv" min="-5000" max="5000" value="-752"></div>
</div>
<div class="control-group">
<h4>LFO</h4>
<div class="row">
<label for="lyraLfoRate">RATE:</label>
<div class="lfo-rate-control">
<input type="range" id="lyraLfoRate" min="0.1" max="20" step="0.1" value="0.3" style="display: none;">
<select id="lyraLfoSyncRate" style="display: block;">
<option value="16">1/64</option>
<option value="8">1/32</option>
<option value="4">1/16</option>
<option value="2">1/8</option>
<option value="1">1/4</option>
<option value="0.5">1/2</option>
<option value="0.25" selected>1 Bar</option>
</select>
<button id="lyraLfoSyncToggle" class="active">SYNC</button>
</div>
</div>
<div class="row"><label for="lyraLfoDepth">DEPTH:</label><input type="range" id="lyraLfoDepth" min="0" max="5000" value="1524"></div>
</div>
<div class="control-group">
<h4>ENVELOPE</h4>
<div class="row"><label for="lyraAttack">ATTACK:</label><input type="range" id="lyraAttack" min="0.01" max="2" step="0.01" value="0.09"></div>
<div class="row"><label for="lyraDecay">DECAY:</label><input type="range" id="lyraDecay" min="0.01" max="2" step="0.01" value="0.05"></div>
<div class="row"><label for="lyraSustain">SUSTAIN:</label><input type="range" id="lyraSustain" min="0" max="1" step="0.01" value="0"></div>
<div class="row"><label for="lyraRelease">RELEASE:</label><input type="range" id="lyraRelease" min="0.01" max="5" step="0.01" value="0.25"></div>
</div>
<div class="control-group">
<h4>EFFECTS</h4>
<div class="row">
<label for="lyraFxType">FX TYPE:</label>
<select id="lyraFxType">
<option value="Delay">Delay</option>
<option value="Phaser">Bubbly Phaser</option>
<option value="Flanger">Pure Flanger</option>
<option value="Quazar">Acidy Quazar</option>
<option value="Chopper" selected>Rhythmic Chopper</option>
<option value="Bitcrusher">Digital Grime</option>
</select>
</div>
<div id="lyraFxControls">
<div id="fxControls-Delay" class="fx-panel" style="display: none;">
<div class="row"><label for="lyraDelayTime">TIME:</label><input type="range" id="lyraDelayTime" min="0.01" max="1.0" step="0.01" value="0.45"></div>
<div class="row"><label for="lyraDelayFeedback">FEEDBACK:</label><input type="range" id="lyraDelayFeedback" min="0" max="0.95" step="0.01" value="0.2"></div>
<div class="row"><label for="lyraDelayMix">MIX:</label><input type="range" id="lyraDelayMix" min="0" max="1.0" step="0.01" value="0.35"></div>
</div>
<div id="fxControls-Phaser" class="fx-panel" style="display: none;">
<div class="row"><label for="phaserRate">RATE:</label><input type="range" id="phaserRate" min="0.1" max="10" step="0.1" value="0.5"></div>
<div class="row"><label for="phaserDepth">DEPTH:</label><input type="range" id="phaserDepth" min="100" max="5000" step="10" value="1200"></div>
<div class="row"><label for="phaserFeedback">FEEDBACK:</label><input type="range" id="phaserFeedback" min="0" max="0.9" step="0.01" value="0.3"></div>
<div class="row"><label for="phaserMix">MIX:</label><input type="range" id="phaserMix" min="0" max="1.0" step="0.01" value="0.5"></div>
</div>
<div id="fxControls-Flanger" class="fx-panel" style="display: none;">
<div class="row"><label for="flangerRate">RATE:</label><input type="range" id="flangerRate" min="0.05" max="5" step="0.01" value="0.2"></div>
<div class="row"><label for="flangerDepth">DEPTH:</label><input type="range" id="flangerDepth" min="0.001" max="0.02" step="0.001" value="0.005"></div>
<div class="row"><label for="flangerFeedback">FEEDBACK:</label><input type="range" id="flangerFeedback" min="0" max="0.95" step="0.01" value="0.5"></div>
<div class="row"><label for="flangerMix">MIX:</label><input type="range" id="flangerMix" min="0" max="1.0" step="0.01" value="0.5"></div>
</div>
<div id="fxControls-Quazar" class="fx-panel" style="display: none;">
<div class="row"><label for="quazarRate">RATE:</label><input type="range" id="quazarRate" min="0.1" max="15" step="0.1" value="2"></div>
<div class="row"><label for="quazarReso">RESO (Q):</label><input type="range" id="quazarReso" min="5" max="50" step="0.5" value="25"></div>
<div class="row"><label for="quazarFreq">CENTER HZ:</label><input type="range" id="quazarFreq" min="200" max="8000" step="10" value="1500"></div>
<div class="row"><label for="quazarMix">MIX:</label><input type="range" id="quazarMix" min="0" max="1.0" step="0.01" value="1.0"></div>
</div>
<div id="fxControls-Chopper" class="fx-panel" style="display: block;">
<div class="row"><label for="chopperRate">RATE:</label>
<select id="chopperRate">
<option value="8">1/32</option>
<option value="4">1/16</option>
<option value="2" selected>1/8</option>
<option value="1">1/4</option>
<option value="0.5">1/2</option>
</select>
</div>
<div class="row"><label for="chopperDepth">DEPTH:</label><input type="range" id="chopperDepth" min="0" max="1.0" step="0.01" value="0.05"></div>
</div>
<div id="fxControls-Bitcrusher" class="fx-panel" style="display: none;">
<div class="row"><label for="crusherBitDepth">BIT DEPTH:</label><input type="range" id="crusherBitDepth" min="1" max="16" step="1" value="8"></div>
<div class="row"><label for="crusherFreq">DOWNSAMPLE:</label><input type="range" id="crusherFreq" min="0" max="1" step="0.01" value="0.5"></div>
<div class="row"><label for="crusherMix">MIX:</label><input type="range" id="crusherMix" min="0" max="1.0" step="0.01" value="0.5"></div>
</div>
</div>
</div>
</div>
</div>
<div id="patchBay">
<h3>PATCH BAY</h3>
<div class="patch-creator">
<div class="row"><label for="patchSource">FROM:</label><select id="patchSource"></select></div>
<div class="row"><label for="patchDest">TO:</label><select id="patchDest"></select></div>
<div class="row"><label for="patchAmount">AMOUNT:</label><input type="range" id="patchAmount" min="-2" max="2" step="0.05" value="0.5" style="width: 280px;"></div><button id="addPatchButton">+ CONNECT PATCH</button>
</div>
<div id="activePatches"></div>
</div>
<h3>SEQUENCER MODULES</h3>
<div class="control-group">
<h4>BD <button onclick="['kickPitch','kickDecay','kickDrive'].forEach(function(id){var e=document.getElementById(id);e.value=(Math.random()*(e.max-e.min)+ +e.min).toFixed(2);e.dispatchEvent(new Event('input'));});">RAND</button></h4>
<div class="row"><label>PITCH:</label><input type="range" id="kickPitch" min="20" max="100" value="51"></div>
<div class="row"><label>DECAY:</label><input type="range" id="kickDecay" min="0.05" max="0.5" step="0.01" value="0.17"></div>
<div class="row"><label>DRIVE:</label><input type="range" id="kickDrive" min="0" max="1" step="0.01" value="0.39"></div>
</div>
<div class="control-group">
<h4>BASS <button onclick="['bassOctave','bassDetune','bassFilterCutoff','bassFilterQ'].forEach(function(id){var e=document.getElementById(id);e.value=(Math.random()*(e.max-e.min)+ +e.min).toFixed(2);e.dispatchEvent(new Event('input'));});">RAND</button></h4>
<div class="row"><label>OCTAVE:</label><input type="range" id="bassOctave" min="-2" max="2" value="-1" step="1"></div>
<div class="row"><label>DETUNE:</label><input type="range" id="bassDetune" min="0" max="25" value="13"></div>
<div class="row"><label>FILTER:</label><input type="range" id="bassFilterCutoff" min="100" max="8000" value="2265"></div>
<div class="row"><label>RESO:</label><input type="range" id="bassFilterQ" min="0" max="20" value="17"></div>
</div>
<div class="control-group">
<h4>SD <button onclick="['snareTone','snareSnappy','snareDecay'].forEach(function(id){var e=document.getElementById(id);e.value=(Math.random()*(e.max-e.min)+ +e.min).toFixed(2);e.dispatchEvent(new Event('input'));});">RAND</button></h4>
<div class="row"><label>TONE:</label><input type="range" id="snareTone" min="100" max="500" value="129"></div>
<div class="row"><label>SNAPPY:</label><input type="range" id="snareSnappy" min="1000" max="9000" value="5245"></div>
<div class="row"><label>DECAY:</label><input type="range" id="snareDecay" min="0.05" max="0.4" step="0.01" value="0.25"></div>
</div>
<div class="control-group">
<h4>HHT <button onclick="['hatDecay','hatMetal'].forEach(function(id){var e=document.getElementById(id);e.value=(Math.random()*(e.max-e.min)+ +e.min).toFixed(2);e.dispatchEvent(new Event('input'));});">RAND</button></h4>
<div class="row"><label>DECAY:</label><input type="range" id="hatDecay" min="0.01" max="0.3" step="0.01" value="0.16"></div>
<div class="row"><label>METAL:</label><input type="range" id="hatMetal" min="5000" max="15000" value="7571"></div>
</div>
<h3>MASTER OUT</h3>
<div class="control-group">
<div class="row">
<button id="safetyToggle" class="active" title="Toggles modulation safety. When active (orange), modulation is clamped to a safe range. When inactive, modulation can go into negative ranges for a harsher effect.">
SAFETY: ON
</button>
</div>
<div class="row"><label for="masterDist">DISTORT:</label><input type="range" id="masterDist" min="0" max="1" step="0.01" value="0.03"></div>
<div class="row"><label for="masterReverb">REVERB:</label><input type="range" id="masterReverb" min="0" max="0.5" step="0.01" value="0.44"></div>
</div>
<br>
</div>
<div id="exportModal" class="modal-overlay">
<div class="modal-content">
<h4>EXPORT PRESET SNIPPET</h4>
<textarea id="exportTextarea" readonly></textarea>
<div class="modal-buttons">
<button id="downloadPresetButton" style="background-color: var(--lyra-color);">DOWNLOAD .TXT</button>
<button id="closeExportModalButton">CLOSE</button>
</div>
</div>
</div>
<div id="importModal" class="modal-overlay">
<div class="modal-content">
<h4>IMPORT PRESET(S)</h4>
<textarea id="importTextarea" placeholder="Paste preset code or bulk JSON here..."></textarea>
<div class="modal-buttons">
<button id="applyImportButton" style="background-color: var(--accent-color);">APPLY</button>
<button id="closeImportModalButton">CLOSE</button>
</div>
</div>
</div>
<!-- Add this new modal at the end of your HTML body -->
<div id="patchEditModal" class="modal-overlay">
<div class="modal-content">
<h4 id="patchEditTitle" style="margin-bottom: 25px;">EDIT PATCH</h4>
<div class="row">
<label for="patchEditAmount" style="flex-basis: 30%;">Amount:</label>
<input type="range" id="patchEditAmount" min="-2" max="2" step="0.05" value="1">
<span id="patchEditAmountValue" style="min-width: 40px; text-align: left;">1.00</span>
</div>
<div class="row">
<label for="patchEditMin" style="flex-basis: 30%;">Mod Min:</label>
<input type="range" id="patchEditMin" min="-1" max="1" step="0.01" value="-1">
<span id="patchEditMinValue" style="min-width: 40px; text-align: left;">-1.00</span>
</div>
<div class="row">
<label for="patchEditMax" style="flex-basis: 30%;">Mod Max:</label>
<input type="range" id="patchEditMax" min="-1" max="1" step="0.01" value="1">
<span id="patchEditMaxValue" style="min-width: 40px; text-align: left;">1.00</span>
</div>
<div class="modal-buttons" style="margin-top: 20px;">
<button id="applyPatchEditButton" style="background-color: var(--accent-color);">APPLY</button>
<button id="closePatchEditModalButton">CLOSE</button>
</div>
</div>
</div>
<div id="synthStepEditModal" class="modal-overlay">
<div class="modal-content">
<h4 id="synthStepEditTitle">EDIT SYNTH STEP</h4>
<!-- Main Controls -->
<div class="synth-step-editor-grid">
<!-- Note Selection -->
<div class="row">
<label for="synthStepNoteSelect">Note:</label>
<select id="synthStepNoteSelect">
<!-- Options will be populated by JavaScript -->
</select>
</div>
<!-- Gate/Duration Control -->
<div class="row">
<label for="synthStepGate">Gate:</label>
<input type="range" id="synthStepGate" min="0.1" max="1.0" step="0.05" value="1.0">
<span id="synthStepGateValue" style="min-width: 40px; text-align: left;">100%</span>
</div>
<!-- Boolean Toggles -->
<div class="row step-toggles">
<div class="toggle-wrapper">
<input type="checkbox" id="synthStepActiveToggle" class="styled-checkbox">
<label for="synthStepActiveToggle">Active</label>
</div>
<div class="toggle-wrapper">
<input type="checkbox" id="synthStepAccentToggle" class="styled-checkbox">
<label for="synthStepAccentToggle">Accent</label>
</div>
<div class="toggle-wrapper">
<input type="checkbox" id="synthStepSlideToggle" class="styled-checkbox">
<label for="synthStepSlideToggle">Slide</label>
</div>
</div>
</div>
<!-- Action Buttons -->
<div class="modal-buttons" style="margin-top: 20px;">
<button id="applySynthStepEditButton" style="background-color: var(--lyra-color);">APPLY</button>
<button id="closeSynthStepEditModalButton">CLOSE</button>
</div>
</div>
</div>
<script type="module">
import * as THREE from 'https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.module.js';
function openCellEditor(audio, row, idx, meta) {
const overlay = document.createElement('div');
overlay.className = 'modal-overlay';
overlay.style.display = 'flex';
const box = document.createElement('div');
box.className = 'modal-content';
box.innerHTML = '<h4>Step ' + row + ' ' + (idx + 1) + '</h4>' +
'Velocity <input id="vel" type="range" min="0" max="1" step="0.01" value="' + meta.velocity + '"><br>' +
'Probability <input id="prob" type="range" min="0" max="1" step="0.01" value="' + meta.prob + '"><br>' +
'Ratchet <input id="rat" type="number" min="1" max="8" value="' + meta.ratchet + '"><br>';
const ok = document.createElement('button');
ok.textContent = 'OK';
const cancel = document.createElement('button');
cancel.textContent = 'CLOSE';
const bar = document.createElement('div');
bar.className = 'modal-buttons';
bar.appendChild(ok);
bar.appendChild(cancel);
box.appendChild(bar);
overlay.appendChild(box);
document.body.appendChild(overlay);
const focusable = Array.from(box.querySelectorAll('input, button'));
let keydownHandler;
const closeModal = () => {
if (keydownHandler) {
document.removeEventListener('keydown', keydownHandler);
}
overlay.remove();
audio.updatePatternEditor(); // Update visuals on close
};
keydownHandler = (e) => {
const firstFocusable = focusable[0];
const lastFocusable = focusable[focusable.length - 1];
if (e.key === 'Escape') {
closeModal();
}
if (e.key === 'Tab') {
if (e.shiftKey) { // Shift+Tab
if (document.activeElement === firstFocusable) {
e.preventDefault();
lastFocusable.focus();
}
} else { // Tab
if (document.activeElement === lastFocusable) {
e.preventDefault();
firstFocusable.focus();
}
}
}
};
document.addEventListener('keydown', keydownHandler);
ok.onclick = () => {
ok.disabled = true;
if (!audio.stepMeta[row]) audio.stepMeta[row] = {};
const ratchetVal = parseInt(box.querySelector('#rat').value, 10);
audio.stepMeta[row][idx] = {
velocity: parseFloat(box.querySelector('#vel').value),
prob: parseFloat(box.querySelector('#prob').value),
ratchet: isNaN(ratchetVal) ? 1 : Math.max(1, Math.min(8, ratchetVal)),
triggerPass: audio.stepMeta[row][idx]?.triggerPass
};
closeModal();
};
cancel.onclick = closeModal;
focusable[0].focus();
}
/**
* =========================================================================
* THE CORRECT CONTEXT MENU MANAGER (The "No-Garbage-UX" Engine)
* - Parent menus STAY OPEN. This is the one you want.
* =========================================================================
*/
class ContextMenuManager {
constructor() {
this.menuStack = [];
this.activeTrigger = null;
this.audio = null;
this._handleKeyDown = this._handleKeyDown.bind(this);
this._handleDocumentClick = this._handleDocumentClick.bind(this);
}
isOpen() {
return this.menuStack.length > 0;
}
open(evt, audio, type, menuDefinition) {
evt.preventDefault();
evt.stopPropagation();
if (this.isOpen() && this.activeTrigger === evt.currentTarget) {
this.closeAll();
return;
}
if (this.isOpen()) this.closeAll();
this.audio = audio;
this.activeTrigger = evt.currentTarget;
const menuEl = this._createMenu(menuDefinition, type);
document.body.appendChild(menuEl);
const rect = menuEl.getBoundingClientRect();
let x = evt.clientX,
y = evt.clientY;
if (x + rect.width > window.innerWidth) x = window.innerWidth - rect.width - 5;
if (y + rect.height > window.innerHeight) y = window.innerHeight - rect.height - 5;
menuEl.style.left = `${x}px`;
menuEl.style.top = `${y}px`;
this.menuStack.push(menuEl);
this._attachGlobalListeners();
}
closeAll() {
this.menuStack.forEach(menu => menu.remove());
this.menuStack = [];
this.activeTrigger = null;
this.audio = null;
this._removeGlobalListeners();
}
_attachGlobalListeners() {
document.addEventListener('click', this._handleDocumentClick, true);
document.addEventListener('keydown', this._handleKeyDown);
}
_removeGlobalListeners() {
document.removeEventListener('click', this._handleDocumentClick, true);
document.removeEventListener('keydown', this._handleKeyDown);
}
_handleDocumentClick(evt) {
if (this.isOpen() && !this.menuStack.some(menu => menu.contains(evt.target))) {
this.closeAll();
}
}
_handleKeyDown(evt) {
if (evt.key === 'Escape') this.closeAll();
}
_createMenu(items, type) {
const menu = document.createElement('div');
menu.className = 'context-menu';
items.forEach(itemData => {
if (itemData.separator) {
const hr = document.createElement('hr');
hr.className = 'context-menu-separator';
menu.appendChild(hr);
return;
}
const item = document.createElement('div');
item.className = 'context-menu-item';
item.textContent = itemData.label;
const isDisabled = typeof itemData.disabled === 'function' ? itemData.disabled(this.audio) : itemData.disabled;
if (isDisabled) {
item.classList.add('disabled');
} else {
if (itemData.action) {
item.onclick = (e) => {
e.stopPropagation();
itemData.action(this.audio, type);
this.closeAll();
};
} else if (itemData.submenu) {
item.textContent += ' ▸';
item.onclick = (e) => {
e.stopPropagation();
const parentMenu = item.closest('.context-menu');
const currentLevel = this.menuStack.indexOf(parentMenu);
while (this.menuStack.length > currentLevel + 1) {
this.menuStack.pop().remove();
}
const subMenuEl = this._createMenu(itemData.submenu, type);
document.body.appendChild(subMenuEl);
const parentRect = item.getBoundingClientRect();
const subRect = subMenuEl.getBoundingClientRect();
let subX = parentRect.right + 2;
let subY = parentRect.top;
if (subX + subRect.width > window.innerWidth) subX = parentRect.left - subRect.width - 2;
if (subY + subRect.height > window.innerHeight) subY = window.innerHeight - subRect.height - 5;
subMenuEl.style.left = `${subX}px`;
subMenuEl.style.top = `${subY}px`;
this.menuStack.push(subMenuEl);
};
}
}
menu.appendChild(item);
});
return menu;
}
}
/**
* =========================================================================
* SINGLE INSTANCE CREATION
* =========================================================================
*/
const contextMenuManager = new ContextMenuManager();
/**
* =========================================================================
* THE FULLY UPGRADED ROW CONTEXT MENU WITH BASS FX
* =========================================================================
*/
function openRowContextMenu(audio, type, evt) {
const clearMeta = () => {
audio.stepMeta[type] = {};
audio.updatePatternEditor();
};
const updateAndClearMeta = (action) => {
action();
clearMeta();
};
const euclidean = (pulses, steps) => {
const pattern = new Array(steps).fill(false);
if (pulses === 0 || pulses > steps) return pattern;
let bucket = 0;
for (let i = 0; i < steps; i++) {
bucket += pulses;
if (bucket >= steps) {
pattern[i] = true;
bucket -= steps;
}
}
return pattern;
};
const setParamOnStep = (stepIndex, param, value) => {
audio.stepMeta[type] = audio.stepMeta[type] || {};
audio.stepMeta[type][stepIndex] = audio.stepMeta[type][stepIndex] || {
velocity: 1,
prob: 1,
ratchet: 1
};
audio.stepMeta[type][stepIndex][param] = value;
};
const allPresets = {
'Kick: Four on the Floor': {
pattern: Array(32).fill(false).map((_, i) => i % 8 === 0),
types: ['kick']
},
'Kick: Heartbeat': {
pattern: Array(32).fill(false).map((_, i) => i % 16 === 0 || i % 16 === 2),
types: ['kick']
},
'Snare: Backbeat': {
pattern: Array(32).fill(false).map((_, i) => i % 8 === 4),
types: ['snare']
},
'Snare: Halftime': {
pattern: Array(32).fill(false).map((_, i) => i === 8 || i === 24),
types: ['snare']
},
'Hat: Every 8th': {
pattern: Array(32).fill(false).map((_, i) => i % 2 === 0),
types: ['hat']
},
'Hat: Off-beat 8th': {
pattern: Array(32).fill(false).map((_, i) => i % 2 !== 0),
types: ['hat']
},
'Hat: Triplets': {
pattern: Array(32).fill(false).map((_, i) => i % 3 === 0),
types: ['hat']
},
'Bass: Standard Pulse': {
pattern: Array(32).fill(false).map((_, i) => i % 4 === 0),
types: ['bass']
},
'Rhythm: Tresillo': {
pattern: [true, false, false, true, false, false, true, false, true, false, false, true, false, false, true, false, true, false, false, true, false, false, true, false, true, false, false, true, false, false, true, false],
types: ['kick', 'snare', 'bass', 'hat']
},
'Gate: Trance Gate': {
pattern: Array(32).fill(true).map((_, i) => i % 4 !== 3),
types: ['lyraGate']
}
};
const availablePresets = Object.entries(allPresets)
.filter(([name, data]) => data.types.includes(type))
.map(([name, data]) => ({
label: name,
action: () => {
audio.sequences[type] = data.pattern.slice(0, audio.sequenceLength);
clearMeta();
}
}));
let menuDefinition = [{
label: 'Copy',
submenu: [{
label: 'Copy Full Pattern',
action: () => {
audio.patternClipboard = {
sequence: [...audio.sequences[type]],
stepMeta: JSON.parse(JSON.stringify(audio.stepMeta[type]))
};
}
},
{
label: 'Copy Steps Only',
action: () => {
audio.patternClipboard = {
sequence: [...audio.sequences[type]]
};
}
},
{
label: 'Copy Parameters Only',
action: () => {
audio.patternClipboard = {
stepMeta: JSON.parse(JSON.stringify(audio.stepMeta[type]))
};
}
}
]
},
{
label: 'Paste',
submenu: [{
label: 'Paste Full Pattern',
action: () => {
if (audio.patternClipboard) {
if (audio.patternClipboard.sequence) audio.sequences[type] = [...audio.patternClipboard.sequence];
if (audio.patternClipboard.stepMeta) audio.stepMeta[type] = JSON.parse(JSON.stringify(audio.patternClipboard.stepMeta));
audio.updatePatternEditor();
}
},
disabled: !audio.patternClipboard
},
{
label: 'Paste Steps Only',
action: () => {
if (audio.patternClipboard?.sequence) {
audio.sequences[type] = [...audio.patternClipboard.sequence];
audio.updatePatternEditor();
}
},
disabled: !audio.patternClipboard?.sequence
},
{
label: 'Paste Parameters Only',
action: () => {
if (audio.patternClipboard?.stepMeta) {
audio.stepMeta[type] = JSON.parse(JSON.stringify(audio.patternClipboard.stepMeta));
audio.updatePatternEditor();
}
},
disabled: !audio.patternClipboard?.stepMeta
}
]
},
{
separator: true
},
{
label: 'Generate',
submenu: [{
label: 'Fill Every 2 Steps',
action: () => updateAndClearMeta(() => audio.sequences[type] = Array(32).fill(false).map((_, i) => i % 2 === 0))
},
{
label: 'Fill Every 4 Steps',
action: () => updateAndClearMeta(() => audio.sequences[type] = Array(32).fill(false).map((_, i) => i % 4 === 0))
},
{
label: 'Euclidean (5/32)',
action: () => updateAndClearMeta(() => audio.sequences[type] = euclidean(5, 32))
},
{
label: 'Euclidean (7/32)',
action: () => updateAndClearMeta(() => audio.sequences[type] = euclidean(7, 32))
},
]
},
{
label: 'Mutate',
submenu: [{
label: 'Invert (Flip On/Off)',
action: () => {
audio.sequences[type] = audio.sequences[type].map(v => !v);
audio.updatePatternEditor();
}
},
{
label: 'Reverse',
action: () => {
audio.sequences[type].reverse();
audio.updatePatternEditor();
}
},
{
label: 'Rotate Left <',
action: () => audio.nudgePattern(type, -1)
},
{
label: 'Rotate Right >',
action: () => audio.nudgePattern(type, 1)
},
{
separator: true
},
{
label: 'Double Speed (Halve)',
action: () => {
const s = audio.sequences[type];
const half = s.slice(0, 16);
audio.sequences[type] = [...half, ...half];
audio.updatePatternEditor();
}
},
{
label: 'Half Speed (Double)',
action: () => {
const s = audio.sequences[type];
const newSeq = [];
for (let i = 0; i < s.length; i += 2) {
newSeq.push(s[i]);
}
audio.sequences[type] = [...newSeq, ...Array(16).fill(false)];
audio.updatePatternEditor();
}
},
{
separator: true
},
{
label: 'Thin Out (Remove 25%)',
action: () => {
audio.sequences[type].forEach((v, i) => {
if (v && Math.random() < 0.25) audio.sequences[type][i] = false;
});
audio.updatePatternEditor();
}
},
{
label: 'Thicken (Add 25%)',
action: () => {
audio.sequences[type].forEach((v, i) => {
if (!v && Math.random() < 0.25) audio.sequences[type][i] = true;
});
audio.updatePatternEditor();
}
},
]
},
{
label: 'Parameters',
submenu: [{
label: 'Velocity Ramp Down',
action: () => {
audio.sequences[type].forEach((v, i) => {
if (v) setParamOnStep(i, 'velocity', 1.0 - (i / 31))
});
audio.updatePatternEditor();
}
},
{
label: 'Velocity Ramp Up',
action: () => {
audio.sequences[type].forEach((v, i) => {
if (v) setParamOnStep(i, 'velocity', i / 31)
});
audio.updatePatternEditor();
}
},
{
label: 'Humanize Velocity',
action: () => {
audio.sequences[type].forEach((v, i) => {
if (v) setParamOnStep(i, 'velocity', 0.6 + Math.random() * 0.4)
});
audio.updatePatternEditor();
}
},
{
separator: true
},
{
label: 'Probability Fade In',
action: () => {
audio.sequences[type].forEach((v, i) => {
if (v) setParamOnStep(i, 'prob', i / 31)
});
audio.updatePatternEditor();
}
},
{
label: 'Probability Fade Out',
action: () => {
audio.sequences[type].forEach((v, i) => {
if (v) setParamOnStep(i, 'prob', 1.0 - (i / 31))
});
audio.updatePatternEditor();
}
},
{
label: 'Humanize Probability',
action: () => {
audio.sequences[type].forEach((v, i) => {
if (v) setParamOnStep(i, 'prob', 0.75 + Math.random() * 0.25)
});
audio.updatePatternEditor();
}
},
{
separator: true
},
{
label: 'Create Ratchet Buildup',
action: () => {
audio.sequences[type].forEach((v, i) => {
if (v) {
let r = 1;
if (i > 23) r = 4;
else if (i > 15) r = 3;
else if (i > 7) r = 2;
setParamOnStep(i, 'ratchet', r);
}
});
audio.updatePatternEditor();
}
},
{
label: 'Add Random Ratchets',
action: () => {
audio.sequences[type].forEach((v, i) => {
if (v && Math.random() < 0.3) setParamOnStep(i, 'ratchet', [2, 3, 4][Math.floor(Math.random() * 3)])
});
audio.updatePatternEditor();
}
},
]
},
{
label: 'Trig Conditions',
submenu: [{
label: 'Set All to 1st Pass',
action: () => {
audio.sequences[type].forEach((v, i) => {
if (v) setParamOnStep(i, 'triggerPass', 'first')
});
audio.updatePatternEditor();
}
},
{
label: 'Set All to 2nd Pass',
action: () => {
audio.sequences[type].forEach((v, i) => {
if (v) setParamOnStep(i, 'triggerPass', 'second')
});
audio.updatePatternEditor();
}
},
{
label: 'Alternate Passes',
action: () => {
audio.sequences[type].forEach((v, i) => {
if (v) setParamOnStep(i, 'triggerPass', i % 2 === 0 ? 'first' : 'second')
});
audio.updatePatternEditor();
}
},
{
separator: true
},
{
label: 'Clear All Conditions',
action: () => {
audio.sequences[type].forEach((v, i) => {
if (v && audio.stepMeta[type]?.[i]) {
delete audio.stepMeta[type][i].triggerPass;
}
});
audio.updatePatternEditor();
}
}
]
},
{
separator: true
},
{
label: 'Presets',
submenu: availablePresets.length > 0 ? availablePresets : [{
label: 'No presets for this row',
disabled: true
}]
},
{
separator: true
},
{
label: 'Clear...',
submenu: [{
label: 'Clear Steps',
action: () => {
audio.sequences[type].fill(false);
clearMeta();
}
},
{
label: 'Clear Parameters Only',
action: () => clearMeta()
},
{
label: 'Clear All (Steps & Params)',
action: () => {
audio.sequences[type].fill(false);
clearMeta();
}
}
]
}
];
contextMenuManager.open(evt, audio, type, menuDefinition);
}
function openSynthStepEditor(audio, stepIndex) {
const modal = document.getElementById('synthStepEditModal');
const stepData = audio.lyraSequencer.steps[stepIndex];
if (!modal || !stepData) return;
// --- Get all modal elements ---
const title = document.getElementById('synthStepEditTitle');
const noteSelect = document.getElementById('synthStepNoteSelect');
const gateSlider = document.getElementById('synthStepGate');
const gateValue = document.getElementById('synthStepGateValue');
const activeToggle = document.getElementById('synthStepActiveToggle');
const accentToggle = document.getElementById('synthStepAccentToggle');
const slideToggle = document.getElementById('synthStepSlideToggle');
const applyBtn = document.getElementById('applySynthStepEditButton');
const closeBtn = document.getElementById('closeSynthStepEditModalButton');
// --- Populate Note Select Dropdown ---
const noteNames = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B', 'C+', 'C#+', 'D+', 'D#+', 'E+', 'F+', 'F#+', 'G+', 'G#+', 'A+', 'A#+', 'B+', 'C++'];
noteSelect.innerHTML = ''; // Clear previous options
noteNames.forEach((name, index) => {
const option = document.createElement('option');
option.value = index;
option.textContent = name;
if (index === stepData.note) {
option.selected = true;
}
noteSelect.appendChild(option);
});
// --- Set initial values from stepData ---
title.textContent = `EDIT SYNTH STEP ${stepIndex + 1}`;
gateSlider.value = stepData.gate ?? 1.0; // Default to 1.0 if not set
gateValue.textContent = `${Math.round(gateSlider.value * 100)}%`;
activeToggle.checked = stepData.active;
accentToggle.checked = stepData.accent;
slideToggle.checked = stepData.slide;
// --- Define event handlers ---
const updateGateValue = () => {
gateValue.textContent = `${Math.round(gateSlider.value * 100)}%`;
};
let keydownHandler; // Declare here to be accessible in closeModal
const closeModal = () => {
modal.style.display = 'none';
// Clean up listeners to prevent memory leaks
gateSlider.removeEventListener('input', updateGateValue);
applyBtn.onclick = null;
closeBtn.onclick = null;
document.removeEventListener('keydown', keydownHandler);
};
const applyChanges = () => {
const newStepData = {
active: activeToggle.checked,
note: parseInt(noteSelect.value, 10),
accent: accentToggle.checked,
slide: slideToggle.checked,
gate: parseFloat(gateSlider.value) // Save the new gate value
};
// Update the main audio system data
audio.lyraSequencer.steps[stepIndex] = newStepData;
// Update the UI to reflect changes
audio.updateLyraSequencerUI();
closeModal();
};
keydownHandler = (e) => {
if (e.key === 'Escape') {
closeModal();
} else if (e.key === 'Enter') {
applyChanges();
}
};
// --- Attach event listeners ---
gateSlider.addEventListener('input', updateGateValue);
applyBtn.onclick = applyChanges;
closeBtn.onclick = closeModal;
document.addEventListener('keydown', keydownHandler);
// --- Show the modal ---
modal.style.display = 'flex';
noteSelect.focus(); // Set focus to the first interactive element
}
class AudioSystem {
constructor() {
this.audioCtx = new(window.AudioContext || window.webkitAudioContext)();
this.reloadUntil = 0;
this.bpm = 138;
this.masterCycleStep = 0;
this.sequenceLength = 32;
this.masterGain = this.audioCtx.createGain();
this.masterGain.gain.value = 0.4;
// --- NEW: Gain nodes for individual voices ---
this.kickGain = this.audioCtx.createGain();
this.snareGain = this.audioCtx.createGain();
this.hatGain = this.audioCtx.createGain();
this.bassGain = this.audioCtx.createGain();
this.synthGain = this.audioCtx.createGain(); // <-- ADD THIS LINE
// Set default volumes
this.kickGain.gain.value = 1.0;
this.snareGain.gain.value = 0.8;
this.hatGain.gain.value = 0.8;
this.bassGain.gain.value = 0.8;
this.synthGain.gain.value = 0.7; // <-- ADD THIS LINE
// Connect individual gains to the master gain
this.kickGain.connect(this.masterGain);
this.snareGain.connect(this.masterGain);
this.hatGain.connect(this.masterGain);
this.bassGain.connect(this.masterGain);
this.synthGain.connect(this.masterGain); // <-- ADD THIS LINE
this.masterDistortion = this.audioCtx.createWaveShaper();
this.setDistortionCurve(0.1);
this.limiter = this.audioCtx.createDynamicsCompressor();
this.limiter.threshold.setValueAtTime(-1.0, this.audioCtx.currentTime, 0);
this.limiter.knee.setValueAtTime(0, this.audioCtx.currentTime, 0);
this.limiter.ratio.setValueAtTime(20.0, this.audioCtx.currentTime, 0);
this.limiter.attack.setValueAtTime(0.001, this.audioCtx.currentTime, 0);
this.limiter.release.setValueAtTime(0.1, this.audioCtx.currentTime, 0);
this.reverbNode = this.createReverb(1.5);
this.reverbSend = this.audioCtx.createGain();
this.reverbSend.gain.value = 0.15;
this.masterGain.connect(this.masterDistortion);
this.masterDistortion.connect(this.limiter);
this.masterDistortion.connect(this.reverbSend);
this.reverbSend.connect(this.reverbNode);
this.reverbNode.connect(this.limiter);
this.limiter.connect(this.audioCtx.destination);
this.analyzer = this.audioCtx.createAnalyser();
this.limiter.connect(this.analyzer);
this.analyzer.fftSize = 256;
this.dataArray = new Uint8Array(this.analyzer.frequencyBinCount);
/* MASTER VU METER WITH HEADER COLOR SWITCH */
const canvas = document.getElementById("masterViz");
const ctx = canvas.getContext("2d");
const ascii = document.querySelector("pre");
function sizeVU(){
const dpr = window.devicePixelRatio || 1;
const cssW = canvas.clientWidth || 600;
const cssH = 1; // target height in CSS pixels
canvas.style.width = cssW + "px";
canvas.style.height = cssH + "px";
canvas.width = Math.max(1, Math.floor(cssW * dpr));
canvas.height = Math.max(1, Math.floor(cssH * dpr));
ctx.setTransform(dpr, 0, 0, dpr, 0, 0); // draw in CSS pixel units
}
sizeVU();
window.addEventListener("resize", sizeVU);
const intensityThreshold = 0.575;
const alpha = 0.85;
const drawViz = () => {
requestAnimationFrame(drawViz);
// flash blue while reloading
if (performance.now() < this.reloadUntil) {
const w = canvas.clientWidth || 600;
const h = canvas.clientHeight || 1;
ctx.clearRect(0, 0, w, h);
ctx.fillStyle = "#0095ff";
ctx.fillRect(0, 0, w, h);
ascii.style.color = "rgba(0,170,255,.85)";
return;
}
this.analyzer.getByteFrequencyData(this.dataArray);
const avg = this.dataArray.reduce((sum, v) => sum + v, 0) / this.dataArray.length;
const w = canvas.clientWidth || 600;
const h = canvas.clientHeight || 1;
ctx.clearRect(0, 0, w, h);
ctx.fillStyle = "#ff8c00";
const frac = avg / 255; // 0..1
ctx.fillRect(0, 0, Math.floor(w * frac), h);
const f = frac;
let color;
if (f > intensityThreshold) {
color = "rgba(0,170,255," + alpha + ")";
} else {
const r = 255;
const g = Math.round(255 - f * 115);
const b = Math.round(255 - f * 255);
color = "rgba(" + r + "," + g + "," + b + "," + alpha + ")";
}
ascii.style.color = color;
};
drawViz();
/* === END VU METER === */
this.patternClipboard = null;
this.params = {
kickPitch: 50,
kickDecay: .15,
kickDrive: .2,
bassOctave: 0,
bassDetune: 12,
bassFilterCutoff: 800,
bassFilterQ: 5,
snareTone: 220,
snareSnappy: 5000,
snareDecay: .12,
hatDecay: .08,
hatMetal: 9000
};
// --- NEW BASS WOBBLE PROPERTY ---
this.bassLfo = null;
// Master volume defaults to 40%
document.getElementById('masterVolume').addEventListener('input', e => {
this.masterGain.gain.value = parseFloat(e.target.value);
});
this.sequences = {
kick: [true, false, false, false, true, false, false, false, true, false, false, false, true, false, false, false, true, false, false, false, true, false, false, false, false, false, true, false, false, false, false, false],
snare: [false, false, false, false, true, false, false, false, false, false, false, false, true, false, false, false, false, false, false, false, true, false, false, false, false, false, false, false, true, false, false, false],
hat: [false, true, true, false, false, false, false, false, true, true, true, true, false, false, false, false, false, true, true, true, false, false, false, false, false, true, true, false, true, false, false, false],
bass: [false, false, true, false, false, false, false, false, false, false, true, true, false, false, false, false, false, false, true, false, true, false, false, false, false, false, true, true, false, false, false, false],
lyraGate: (() => {
const arr = Array(32).fill(true);
arr[14] = false;
arr[27] = false;
arr[31] = false;
return arr;
})()
};
this.stepMeta = {
kick: {},
snare: {},
hat: {},
bass: {},
lyraGate: {}
};
this.presetVersion = 2;
this.lyraGlobalGateProbability = 1.0;
this.lyraParams = {
osc1Type: 'sine',
osc1Oct: 0,
osc2Type: 'sine',
osc2Oct: -1,
detune: 30,
fmAmount: 600,
waveFold: 0.2,
drive: 0.175,
driveMode: 'soft clip',
filterType: 'lowpass',
filterCutoff: 3000,
filterQ: 14.4,
filterEnv: 2500,
lfoRate: 0.3,
lfoSyncRate: '0.25',
lfoIsSynced: true,
lfoDepth: 2000,
attack: 0.3,
decay: 0.6,
sustain: 0.4,
release: 2.25,
portamento: 0.08
};
this.numLyraVoices = 8;
this.lyraVoices = [];
this.lyraVoiceIndex = 0;
this.lyraFxIn = this.audioCtx.createGain();
this.lyraFxOut = this.audioCtx.createGain();
this.lyraFxOut.connect(this.synthGain); // <-- CHANGE THIS CONNECTION
this.lyraFxNodes = {};
this.lyraSequencer = {
isPlaying: true,
currentStep: 0,
stepLength: 16,
forceGate: true,
steps: [{
active: true,
note: 3,
slide: false,
accent: true,
gate: 1.0
},
{
active: true,
note: 8,
slide: false,
accent: false,
gate: 1.0
},
{
active: true,
note: 0,
slide: false,
accent: false,
gate: 1.0
},
{
active: true,
note: 10,
slide: true,
accent: true,
gate: 1.0
},
{
active: false,
note: 0,
slide: false,
accent: false,
gate: 1.0
},
{
active: true,
note: 5,
slide: false,
accent: false,
gate: 1.0
},
{
active: true,
note: 14,
slide: false,
accent: true,
gate: 1.0
},
{
active: true,
note: 2,
slide: false,
accent: false,
gate: 1.0
},
{
active: true,
note: 3,
slide: false,
accent: false,
gate: 1.0
},
{
active: true,
note: 14,
slide: false,
accent: false,
gate: 1.0
},
{
active: true,
note: 15,
slide: false,
accent: false,
gate: 1.0
},
{
active: true,
note: 5,
slide: false,
accent: false,
gate: 1.0
},
{
active: false,
note: 0,
slide: false,
accent: false,
gate: 1.0
},
{
active: true,
note: 7,
slide: true,
accent: false,
gate: 1.0
},
{
active: true,
note: 7,
slide: false,
accent: false,
gate: 1.0
},
{
active: false,
note: 0,
slide: false,
accent: false,
gate: 1.0
}
],
baseNote: 48,
lastPlayedFreq: 0,
nextNoteTime: 0
};
this.lyraLfoNode = this.audioCtx.createOscillator();
this.lyraLfoNode.start();
this.lyraLfoGain = this.audioCtx.createGain();
this.lyraLfoNode.connect(this.lyraLfoGain);
this.lyraLooper = {
isPlaying: false,
isRecording: false,
notes: [],
loopStartTime: 0,
loopDuration: 0,
nextNoteIndex: 0
};
this.lyraLooperTimerID = null;
this.patches = [];
this.modSources = {
'KAOS X': {
value: .5
},
'KAOS Y': {
value: .5
},
'LFO 1 (Slow)': {
value: 0,
phase: 0
},
'LFO 1 (Slower)': {
value: 0,
phase: 0,
rate: 0.02
},
'LFO 2 (Fast)': {
value: 0,
phase: 0
},
'LFO 2 (Faster)': {
value: 0,
phase: 0,
rate: 0.3
},
'LFO MIX': {
value: 0
},
'S&H (Random)': {
value: 0
},
'S&H (2 steps)': {
value: 0
},
'S&H (4 steps)': {
value: 0
},
'S&H (8 steps)': {
value: 0
},
'S&H (16 steps)': {
value: 0
},
'S&H (32 steps)': {
value: 0
},
'S&H (64 steps)': {
value: 0
},
'BD ENV': {
value: 0,
analyzer: this.createVoiceAnalyzer()
},
'SD ENV': {
value: 0,
analyzer: this.createVoiceAnalyzer()
},
'LYRA ENV': {
value: 0
},
'LYRA LFO': {
node: this.lyraLfoGain,
value: 0
},
'CLK/2': {
value: 0
},
'CLK/4': {
value: 0
},
'CLK/8': {
value: 0
},
'CLK/16': {
value: 0
},
'CLK/32': {
value: 0
}
};
// =======================================================================
// NEW GLOBAL MODULATION & SWING PROPERTIES
// =======================================================================
this.modsFrozen = false;
this.isMuted = false; // <-- ADD THIS NEW FLAG
this.globalModScale = 1.0;
this.swing = 0;
// =======================================================================
const createWaveTypeSetter = (v) => {
if (v < 0.25) return "sine";
if (v < 0.5) return "square";
if (v < 0.75) return "sawtooth";
return "triangle";
};
this.modDestinationsUnsafe = {
// --- Drum Modules (Mostly safe already, but BASS FILTER needed a fix) ---
'BD DECAY': v => this.params.kickDecay = .05 + Math.max(0, v) * .8,
'BD DRIVE': v => this.params.kickDrive = Math.max(0, v),
'BD PITCH': v => this.params.kickPitch = Math.max(1, 50 + v * 50), // WAS: 50 + v * 50. Clamped to 1Hz.
'HHT DECAY': v => this.params.hatDecay = .01 + Math.max(0, v) * .4,
'HHT METAL': v => this.params.hatMetal = Math.max(100, 8000 + v * 7000), // Clamped to prevent issues
'SD SNAPPY': v => this.params.snareSnappy = Math.max(100, 5000 + v * 4000), // Clamped
'SD TONE': v => this.params.snareTone = Math.max(20, 220 + v * 200), // Clamped
'SNARE DECAY': v => this.params.snareDecay = 0.05 + Math.max(0, v) * 0.35,
'BASS DETUNE': v => this.params.bassDetune = Math.max(0, v) * 50,
'BASS FILTER': v => this.params.bassFilterCutoff = Math.max(20, 100 + v * 7900), // WAS: 100 + v * 7900. Now clamped.
'BASS OCTAVE': v => this.params.bassOctave = Math.round(v * 2),
'BASS RESO': v => this.params.bassFilterQ = Math.max(0, v) * 25,
// --- Master Bus ---
'MASTER DIST': v => this.setDistortionCurve(Math.max(0, v)),
'MASTER REVERB': v => this.reverbSend.gain.setTargetAtTime(Math.max(0, v) * .7, this.audioCtx.currentTime, .01),
// --- Lyra Synth Main ---
'LYRA DETUNE': v => this.updateLyraParam('detune', v * 50),
'LYRA DRIVE': v => this.updateLyraParam('drive', Math.max(0, v) * parseFloat(document.getElementById('lyraDrive').max)), // WAS: v * parseFloat(...). Now clamped.
'LYRA FM AMT': v => this.updateLyraParam('fmAmount', v * 2000),
'LYRA FOLD': v => this.updateLyraParam('waveFold', v),
'LYRA OSC1 OCT': v => this.updateLyraParam('osc1Oct', Math.min(2, Math.max(-2, Math.round(v * 2)))),
'LYRA OSC2 OCT': v => this.updateLyraParam('osc2Oct', Math.min(2, Math.max(-2, Math.round(v * 2)))),
'LYRA FILTER': v => this.updateLyraParam('filterCutoff', Math.max(20, 20 + v * 19980)), // WAS: 20 + v * 19980. Now clamped.
'LYRA FILTER ENV': v => this.updateLyraParam('filterEnv', v * 5000),
'LYRA RESO': v => this.updateLyraParam('filterQ', Math.max(0, v) * parseFloat(document.getElementById('lyraFilterQ').max)), // WAS: v * parseFloat(...). Now clamped.
// --- Lyra Synth Envelope & LFO ---
"LYRA ATTACK": v => this.updateLyraParam("attack", Math.max(0.001, Math.pow(v, 2)) * 0.5),
"LYRA DECAY": v => this.updateLyraParam("decay", Math.max(0.001, Math.pow(v, 2)) * 0.5),
"LYRA SUSTAIN": v => this.updateLyraParam("sustain", Math.max(0, v) * 0.8),
"LYRA RELEASE": v => this.updateLyraParam("release", Math.max(0.001, Math.pow(v, 2)) * 1),
'LYRA SLIDE': v => {
const basePortamento = parseFloat(document.getElementById('lyraPortamento').value) / 1000;
const finalPortamento = basePortamento + (v * 0.2);
this.updateLyraParam('portamento', Math.max(0.001, finalPortamento));
},
'LYRA LFO DEPTH': v => this.updateLyraParam('lfoDepth', v * 5000),
'LYRA LFO RATE': v => this.updateLyraParam('lfoRate', Math.max(0.01, v * 20)), // WAS: v * 20. Now clamped.
// --- Lyra FX ---
'LYRA DELAY FB': v => {
if (this.lyraFxNodes.delay) this.lyraFxNodes.delay.feedback.gain.setTargetAtTime(Math.max(0, v) * 0.95, this.audioCtx.currentTime, 0.01)
},
'LYRA DELAY MIX': v => {
if (document.getElementById('lyraFxType').value === 'Delay') {
const mix = Math.max(0, v);
this.lyraFxNodes.wet.gain.setTargetAtTime(mix, this.audioCtx.currentTime, 0.01);
this.lyraFxNodes.dry.gain.setTargetAtTime(1.0 - mix, this.audioCtx.currentTime, 0.01);
}
},
'LYRA DELAY TIME': v => {
if (this.lyraFxNodes.delay) this.lyraFxNodes.delay.delay.delayTime.setTargetAtTime(Math.max(0.01, v), this.audioCtx.currentTime, 0.01)
}, // FIXED
'PHASER DEPTH': v => {
if (this.lyraFxNodes.phaser) this.lyraFxNodes.phaser.lfoGain.gain.setTargetAtTime(Math.max(0, v) * 5000, this.audioCtx.currentTime, 0.01);
},
'PHASER FEEDBACK': v => {
if (this.lyraFxNodes.phaser) this.lyraFxNodes.phaser.feedback.gain.setTargetAtTime(Math.max(0, v) * 0.9, this.audioCtx.currentTime, 0.01);
},
'PHASER RATE': v => {
if (this.lyraFxNodes.phaser) this.lyraFxNodes.phaser.lfo.frequency.setTargetAtTime(Math.max(0.1, v) * 10, this.audioCtx.currentTime, 0.01);
}, // WAS: Math.max(0.1, v). Now scales correctly.
'FLANGER DEPTH': v => {
if (this.lyraFxNodes.flanger) this.lyraFxNodes.flanger.lfoGain.gain.setTargetAtTime(Math.max(0, v) * 0.02, this.audioCtx.currentTime, 0.01);
},
'FLANGER RATE': v => {
if (this.lyraFxNodes.flanger) this.lyraFxNodes.flanger.lfo.frequency.setTargetAtTime(Math.max(0.05, v * 5), this.audioCtx.currentTime, 0.01);
}, // WAS: Math.max(0.05, v). Now scales.
'CHOPPER DEPTH': v => {
if (this.lyraFxNodes.chopper) this.lyraFxNodes.chopper.lfoGain.gain.setTargetAtTime(Math.max(0, v), this.audioCtx.currentTime, 0.01);
},
'CRUSHER BITDEPTH': v => {
if (this.lyraFxNodes.bitcrusher?.node) this.lyraFxNodes.bitcrusher.node.parameters.get('bitDepth').setTargetAtTime(1 + Math.max(0, v) * 15, this.audioCtx.currentTime, 0.01);
},
'CRUSHER DOWNSAMPLE': v => {
if (this.lyraFxNodes.bitcrusher?.node) this.lyraFxNodes.bitcrusher.node.parameters.get('frequencyReduction').setTargetAtTime(Math.max(0, v), this.audioCtx.currentTime, 0.01);
},
'QUAZAR FREQ': v => {
if (this.lyraFxNodes.quazar) this.lyraFxNodes.quazar.lfoGain.gain.setTargetAtTime(Math.max(20, v * 8000), this.audioCtx.currentTime, 0.01);
}, // WAS: Math.max(0, v). Now scales correctly and clamped.
'QUAZAR RATE': v => {
if (this.lyraFxNodes.quazar) this.lyraFxNodes.quazar.lfo.frequency.setTargetAtTime(Math.max(0.1, v * 15), this.audioCtx.currentTime, 0.01);
}, // WAS: Math.max(0.1, v). Now scales.
'QUAZAR RESO': v => {
if (this.lyraFxNodes.quazar) this.lyraFxNodes.quazar.filter.Q.setTargetAtTime(Math.max(0, v) * 50, this.audioCtx.currentTime, 0.01);
}, // WAS: Math.max(0, v). Now scales.
'LYRA GATE PROB': v => this.lyraGlobalGateProbability = Math.max(0, v),
'LYRA OSC1 TYPE': v => this.updateLyraParam("osc1Type", createWaveTypeSetter(v)),
'LYRA OSC2 TYPE': v => this.updateLyraParam("osc2Type", createWaveTypeSetter(v)),
};
this.modDestinationsSafe = {
'BD DECAY': v => this.params.kickDecay = .05 + ((v + 1) / 2) * .8,
'BD DRIVE': v => this.params.kickDrive = ((v + 1) / 2),
'BD PITCH': v => this.params.kickPitch = 50 + ((v + 1) / 2) * 50,
'HHT DECAY': v => this.params.hatDecay = .01 + ((v + 1) / 2) * .4,
'HHT METAL': v => this.params.hatMetal = 8000 + ((v + 1) / 2) * 7000,
'SD SNAPPY': v => this.params.snareSnappy = 5000 + ((v + 1) / 2) * 4000,
'SD TONE': v => this.params.snareTone = 220 + ((v + 1) / 2) * 200,
'SNARE DECAY': v => this.params.snareDecay = 0.05 + ((v + 1) / 2) * 0.35,
'BASS DETUNE': v => this.params.bassDetune = ((v + 1) / 2) * 50,
'BASS FILTER': v => this.params.bassFilterCutoff = 100 + ((v + 1) / 2) * 7900,
'BASS OCTAVE': v => this.params.bassOctave = Math.round(((v + 1) / 2) * 4 - 2), // Maps [0,1] to [-2,2]
'BASS RESO': v => this.params.bassFilterQ = ((v + 1) / 2) * 25,
'MASTER DIST': v => this.setDistortionCurve(((v + 1) / 2)),
'MASTER REVERB': v => this.reverbSend.gain.setTargetAtTime(((v + 1) / 2) * .7, this.audioCtx.currentTime, .01),
'LYRA DETUNE': v => this.updateLyraParam('detune', ((v + 1) / 2) * 50),
'LYRA DRIVE': v => this.updateLyraParam('drive', ((v + 1) / 2) * parseFloat(document.getElementById('lyraDrive').max)),
'LYRA FM AMT': v => this.updateLyraParam('fmAmount', ((v + 1) / 2) * 2000),
'LYRA FOLD': v => this.updateLyraParam('waveFold', ((v + 1) / 2)),
'LYRA OSC1 OCT': v => this.updateLyraParam('osc1Oct', Math.min(2, Math.max(-2, Math.round(((v + 1) / 2) * 4 - 2)))),
'LYRA OSC2 OCT': v => this.updateLyraParam('osc2Oct', Math.min(2, Math.max(-2, Math.round(((v + 1) / 2) * 4 - 2)))),
'LYRA FILTER': v => this.updateLyraParam('filterCutoff', 20 + ((v + 1) / 2) * 19980),
'LYRA FILTER ENV': v => this.updateLyraParam('filterEnv', v * 5000), // Bipolar is often desired for filter env
'LYRA RESO': v => this.updateLyraParam('filterQ', ((v + 1) / 2) * parseFloat(document.getElementById('lyraFilterQ').max)),
"LYRA ATTACK": v => this.updateLyraParam("attack", Math.max(0.001, Math.pow(((v + 1) / 2), 2)) * 0.5),
"LYRA DECAY": v => this.updateLyraParam("decay", Math.max(0.001, Math.pow(((v + 1) / 2), 2)) * 0.5),
"LYRA SUSTAIN": v => this.updateLyraParam("sustain", ((v + 1) / 2) * 0.8),
"LYRA RELEASE": v => this.updateLyraParam("release", Math.max(0.001, Math.pow(((v + 1) / 2), 2)) * 1),
'LYRA SLIDE': v => {
const basePortamento = parseFloat(document.getElementById('lyraPortamento').value) / 1000;
// The safe 'v' is already scaled to [0, 1]. We'll use it to add to the base time.
const modulationAmount = ((v + 1) / 2) * 0.2;
this.updateLyraParam('portamento', basePortamento + modulationAmount);
},
'LYRA LFO DEPTH': v => this.updateLyraParam('lfoDepth', ((v + 1) / 2) * 5000),
'LYRA LFO RATE': v => this.updateLyraParam('lfoRate', ((v + 1) / 2) * 20),
'LYRA DELAY FB': v => {
if (this.lyraFxNodes.delay) this.lyraFxNodes.delay.feedback.gain.setTargetAtTime(((v + 1) / 2) * 0.95, this.audioCtx.currentTime, 0.01)
},
'LYRA DELAY MIX': v => {
if (document.getElementById('lyraFxType').value === 'Delay') {
const mix = ((v + 1) / 2);
this.lyraFxNodes.wet.gain.setTargetAtTime(mix, this.audioCtx.currentTime, 0.01);
this.lyraFxNodes.dry.gain.setTargetAtTime(1.0 - mix, this.audioCtx.currentTime, 0.01);
}
},
'LYRA DELAY TIME': v => {
if (this.lyraFxNodes.delay) this.lyraFxNodes.delay.delay.delayTime.setTargetAtTime(Math.max(0.01, ((v + 1) / 2)), this.audioCtx.currentTime, 0.01)
}, // FIXED
'PHASER DEPTH': v => {
if (this.lyraFxNodes.phaser) this.lyraFxNodes.phaser.lfoGain.gain.setTargetAtTime(((v + 1) / 2) * 5000, this.audioCtx.currentTime, 0.01);
},
'PHASER FEEDBACK': v => {
if (this.lyraFxNodes.phaser) this.lyraFxNodes.phaser.feedback.gain.setTargetAtTime(((v + 1) / 2) * 0.9, this.audioCtx.currentTime, 0.01);
},
'PHASER RATE': v => {
if (this.lyraFxNodes.phaser) this.lyraFxNodes.phaser.lfo.frequency.setTargetAtTime(Math.max(0.1, ((v + 1) / 2)) * 10, this.audioCtx.currentTime, 0.01);
},
'FLANGER DEPTH': v => {
if (this.lyraFxNodes.flanger) this.lyraFxNodes.flanger.lfoGain.gain.setTargetAtTime(((v + 1) / 2) * 0.02, this.audioCtx.currentTime, 0.01);
},
'FLANGER RATE': v => {
if (this.lyraFxNodes.flanger) this.lyraFxNodes.flanger.lfo.frequency.setTargetAtTime(Math.max(0.05, ((v + 1) / 2)) * 5, this.audioCtx.currentTime, 0.01);
},
'CHOPPER DEPTH': v => {
if (this.lyraFxNodes.chopper) this.lyraFxNodes.chopper.lfoGain.gain.setTargetAtTime(((v + 1) / 2), this.audioCtx.currentTime, 0.01);
},
'CRUSHER BITDEPTH': v => {
if (this.lyraFxNodes.bitcrusher?.node) this.lyraFxNodes.bitcrusher.node.parameters.get('bitDepth').setTargetAtTime(1 + ((v + 1) / 2) * 15, this.audioCtx.currentTime, 0.01);
},
'CRUSHER DOWNSAMPLE': v => {
if (this.lyraFxNodes.bitcrusher?.node) this.lyraFxNodes.bitcrusher.node.parameters.get('frequencyReduction').setTargetAtTime(((v + 1) / 2), this.audioCtx.currentTime, 0.01);
},
'QUAZAR FREQ': v => {
if (this.lyraFxNodes.quazar) this.lyraFxNodes.quazar.lfoGain.gain.setTargetAtTime(((v + 1) / 2) * 8000, this.audioCtx.currentTime, 0.01);
},
'QUAZAR RATE': v => {
if (this.lyraFxNodes.quazar) this.lyraFxNodes.quazar.lfo.frequency.setTargetAtTime(Math.max(0.1, ((v + 1) / 2)) * 15, this.audioCtx.currentTime, 0.01);
},
'QUAZAR RESO': v => {
if (this.lyraFxNodes.quazar) this.lyraFxNodes.quazar.filter.Q.setTargetAtTime(((v + 1) / 2) * 50, this.audioCtx.currentTime, 0.01);
},
'LYRA GATE PROB': v => this.lyraGlobalGateProbability = ((v + 1) / 2),
'LYRA OSC1 TYPE': v => this.updateLyraParam("osc1Type", createWaveTypeSetter((v + 1) / 2)),
'LYRA OSC2 TYPE': v => this.updateLyraParam("osc2Type", createWaveTypeSetter((v + 1) / 2)),
};
this.isSafetyOn = false;
this.nextNoteTime = 0;
this.scheduleAheadTime = .1;
this.timerID = null;
this.scheduler = this.scheduler.bind(this);
this.updateModulation = this.updateModulation.bind(this);
this.scheduleLyraLoopNotes = this.scheduleLyraLoopNotes.bind(this);
}
async initFx() {
const bitcrusherProcessor = `class BitcrusherProcessor extends AudioWorkletProcessor { static get parameterDescriptors() { return [ { name: 'bitDepth', defaultValue: 8, minValue: 1, maxValue: 16 }, { name: 'frequencyReduction', defaultValue: 0.5, minValue: 0, maxValue: 1 } ]; } constructor() { super(); this.phase = 0; this.lastSampleValue = 0; } process(inputs, outputs, parameters) { const input = inputs[0]; const output = outputs[0]; const bitDepth = parameters.bitDepth; const freqRed = parameters.frequencyReduction; for (let channel = 0; channel < input.length; channel++) { const inputChannel = input[channel]; const outputChannel = output[channel]; for (let i = 0; i < inputChannel.length; i++) { const step = Math.pow(0.5, bitDepth.length > 1 ? bitDepth[i] : bitDepth[0]); this.phase += freqRed.length > 1 ? freqRed[i] : freqRed[0]; if (this.phase >= 1.0) { this.phase -= 1.0; this.lastSampleValue = step * Math.floor(inputChannel[i] / step + 0.5); } outputChannel[i] = this.lastSampleValue; } } return true; } } registerProcessor('bitcrusher-processor', BitcrusherProcessor);`;
const blob = new Blob([bitcrusherProcessor], {
type: 'application/javascript'
});
const url = URL.createObjectURL(blob);
try {
await this.audioCtx.audioWorklet.addModule(url);
URL.revokeObjectURL(url);
this.lyraFxNodes.bitcrusher = {
node: new AudioWorkletNode(this.audioCtx, 'bitcrusher-processor')
};
} catch (e) {
console.error("Error loading Bitcrusher worklet:", e);
const crusherOption = document.querySelector('#lyraFxType option[value="Bitcrusher"]');
if (crusherOption) {
crusherOption.textContent = "Digital Grime (Failed to load)";
crusherOption.disabled = true;
}
}
const fx = this.lyraFxNodes;
fx.wet = this.audioCtx.createGain();
fx.dry = this.audioCtx.createGain();
fx.wet.connect(this.lyraFxOut);
fx.dry.connect(this.lyraFxOut);
fx.delay = {
delay: this.audioCtx.createDelay(1.0),
feedback: this.audioCtx.createGain()
};
fx.delay.feedback.gain.value = 0.6;
fx.delay.delay.connect(fx.delay.feedback);
fx.delay.feedback.connect(fx.delay.delay);
fx.phaser = {
lfo: this.audioCtx.createOscillator(),
lfoGain: this.audioCtx.createGain(),
feedback: this.audioCtx.createGain(),
filters: [],
input: this.audioCtx.createGain()
};
let lastNode = fx.phaser.input;
for (let i = 0; i < 6; i++) {
const filter = this.audioCtx.createBiquadFilter();
filter.type = 'allpass';
filter.frequency.value = 1000;
fx.phaser.filters.push(filter);
lastNode.connect(filter);
lastNode = filter;
}
lastNode.connect(fx.phaser.feedback);
fx.phaser.feedback.connect(fx.phaser.input);
fx.phaser.lfo.connect(fx.phaser.lfoGain);
fx.phaser.filters.forEach(f => fx.phaser.lfoGain.connect(f.frequency));
fx.phaser.lfo.start();
fx.flanger = {
lfo: this.audioCtx.createOscillator(),
lfoGain: this.audioCtx.createGain(),
delay: this.audioCtx.createDelay(1.0),
feedback: this.audioCtx.createGain()
};
fx.flanger.delay.delayTime.value = 0.005;
fx.flanger.lfo.connect(fx.flanger.lfoGain);
fx.flanger.lfoGain.connect(fx.flanger.delay.delayTime);
fx.flanger.delay.connect(fx.flanger.feedback);
fx.flanger.feedback.connect(fx.flanger.delay);
fx.flanger.lfo.start();
fx.quazar = {
lfo: this.audioCtx.createOscillator(),
lfoGain: this.audioCtx.createGain(),
filter: this.audioCtx.createBiquadFilter()
};
fx.quazar.filter.type = 'bandpass';
fx.quazar.lfo.connect(fx.quazar.lfoGain);
fx.quazar.lfoGain.connect(fx.quazar.filter.frequency);
fx.quazar.lfo.start();
fx.chopper = {
lfo: this.audioCtx.createOscillator(),
lfoGain: this.audioCtx.createGain(),
node: this.audioCtx.createGain(),
};
fx.chopper.lfo.type = 'square';
fx.chopper.lfo.connect(fx.chopper.lfoGain);
fx.chopper.lfoGain.connect(fx.chopper.node.gain);
fx.chopper.lfo.start();
for (let i = 0; i < this.numLyraVoices; i++) {
const voice = {
isActive: false,
noteFrequency: 0,
osc1: this.audioCtx.createOscillator(),
osc2: this.audioCtx.createOscillator(),
fmGain: this.audioCtx.createGain(),
waveShaper: this.audioCtx.createWaveShaper(),
drive: this.audioCtx.createWaveShaper(),
filter: this.audioCtx.createBiquadFilter(),
env: this.audioCtx.createGain(),
filterEnv: this.audioCtx.createGain()
};
voice.osc1.connect(voice.fmGain);
voice.fmGain.connect(voice.osc2.frequency);
voice.osc1.connect(voice.waveShaper);
voice.osc2.connect(voice.waveShaper);
voice.waveShaper.connect(voice.drive);
voice.drive.connect(voice.filter);
voice.filter.connect(voice.env);
this.lyraLfoGain.connect(voice.filter.frequency);
voice.env.connect(voice.filterEnv);
voice.filterEnv.connect(voice.filter.frequency);
voice.env.connect(this.lyraFxIn);
voice.env.gain.value = 0;
voice.osc1.start();
voice.osc2.start();
this.lyraVoices.push(voice);
}
this.updateLyraParam('all');
this.switchLyraFx(document.getElementById('lyraFxType').value);
}
switchLyraFx(fxType) {
const fx = this.lyraFxNodes;
const now = this.audioCtx.currentTime;
this.lyraFxIn.disconnect();
fx.delay.delay.disconnect();
fx.phaser.filters[5].disconnect();
fx.flanger.delay.disconnect();
fx.quazar.filter.disconnect();
if (fx.bitcrusher?.node) fx.bitcrusher.node.disconnect();
fx.chopper.node.disconnect();
fx.delay.delay.connect(fx.delay.feedback);
fx.phaser.filters[5].connect(fx.phaser.feedback);
fx.flanger.delay.connect(fx.flanger.feedback);
if (fxType === 'Chopper') {
fx.wet.gain.setTargetAtTime(0, now, 0.01);
fx.dry.gain.setTargetAtTime(0, now, 0.01);
this.lyraFxIn.connect(fx.chopper.node);
fx.chopper.node.connect(this.lyraFxOut);
return;
}
let mixValue = 0.5;
const mixSliderIdMap = {
'Delay': 'lyraDelayMix',
'Phaser': 'phaserMix',
'Flanger': 'flangerMix',
'Quazar': 'quazarMix',
'Bitcrusher': 'crusherMix',
};
const mixSlider = document.getElementById(mixSliderIdMap[fxType]);
if (mixSlider) {
mixValue = parseFloat(mixSlider.value);
}
fx.wet.gain.setTargetAtTime(mixValue, now, 0.01);
fx.dry.gain.setTargetAtTime(1.0 - mixValue, now, 0.01);
this.lyraFxIn.connect(fx.dry);
switch (fxType) {
case 'Delay':
this.lyraFxIn.connect(fx.delay.delay);
fx.delay.delay.connect(fx.wet);
break;
case 'Phaser':
this.lyraFxIn.connect(fx.phaser.input);
fx.phaser.filters[5].connect(fx.wet);
break;
case 'Flanger':
this.lyraFxIn.connect(fx.flanger.delay);
fx.flanger.delay.connect(fx.wet);
break;
case 'Quazar':
this.lyraFxIn.connect(fx.quazar.filter);
fx.quazar.filter.connect(fx.wet);
break;
case 'Bitcrusher':
if (fx.bitcrusher?.node) {
this.lyraFxIn.connect(fx.bitcrusher.node);
fx.bitcrusher.node.connect(fx.wet);
}
break;
}
}
setParam(t, n, v) {
if (t === 'pulsar') this.params[n] = parseFloat(v);
}
updateDriveCurve(driveNode, mode, amount) {
const n_samples = 44100;
const curve = new Float32Array(n_samples);
const foldAmount = 1 + amount * 10;
const expAmount = 1 + amount * 10;
for (let i = 0; i < n_samples; i++) {
let x = i * 2 / n_samples - 1;
let y;
switch (mode) {
case 'soft clip':
const k = amount * 100;
y = (Math.PI + k) * x / (Math.PI + k * Math.abs(x));
break;
case 'hard clip':
const driveAmount = 1 + amount * 49;
const clipped = x * driveAmount;
y = Math.max(-1, Math.min(1, clipped));
break;
case 'exponential':
const expInput = x * expAmount;
y = expInput * Math.exp(-Math.abs(expInput));
y *= Math.E; // Scale output to be closer to [-1, 1]
break;
case 'wavefold':
let val = x * foldAmount;
// Use a limit to prevent infinite loops with extreme values if they ever occur
for (let j = 0; j < 10; j++) {
if (val > 1) {
val = 2 - val;
} else if (val < -1) {
val = -2 - val;
} else {
break;
}
}
y = val;
break;
default:
y = x;
}
curve[i] = y;
}
driveNode.curve = curve;
}
updateLyraParam(paramName, value) {
if (paramName !== 'all' && value !== undefined) {
this.lyraParams[paramName] = value;
}
const p = this.lyraParams;
const now = this.audioCtx.currentTime;
let lfoFrequency;
if (p.lfoIsSynced) {
const noteDuration = 60.0 / this.bpm;
lfoFrequency = (1 / noteDuration) * p.lfoSyncRate;
} else {
lfoFrequency = p.lfoRate;
}
this.lyraLfoNode.frequency.setTargetAtTime(lfoFrequency, now, 0.01);
this.lyraLfoGain.gain.setTargetAtTime(p.lfoDepth, now, 0.01);
this.lyraVoices.forEach(voice => {
voice.osc1.type = p.osc1Type;
voice.osc2.type = p.osc2Type;
voice.fmGain.gain.setTargetAtTime(p.fmAmount, now, 0.01);
this.updateDriveCurve(voice.drive, p.driveMode, p.drive);
voice.filter.type = p.filterType;
voice.filter.Q.setTargetAtTime(p.filterQ, now, 0.01);
voice.filter.frequency.setTargetAtTime(p.filterCutoff, now, 0.01);
voice.filterEnv.gain.value = p.filterEnv;
const foldAmount = p.waveFold * 25;
if (foldAmount === 0) {
voice.waveShaper.curve = null;
} else {
const n_samples = 44100;
const curve = new Float32Array(n_samples);
for (let i = 0; i < n_samples; i++) {
const x = i * 2 / n_samples - 1;
curve[i] = Math.tanh(x * foldAmount);
}
voice.waveShaper.curve = curve;
}
});
}
getModSources() {
return Object.keys(this.modSources);
}
getModDestinations() {
return Object.keys(this.modDestinationsSafe); // Return safe version as the canonical list
}
addPatch(s, d, a) {
this.patches.push({
id: crypto.randomUUID(),
source: s,
dest: d,
amount: a,
isLocked: false,
lockedValue: 0,
modRangeMin: -1.0,
modRangeMax: 1.0
});
this.renderPatchesUI();
}
removePatch(id) {
this.patches = this.patches.filter(p => p.id !== id);
this.renderPatchesUI();
}
openPatchEditor(patchId) {
const modal = document.getElementById('patchEditModal');
const patch = this.patches.find(p => p.id === patchId);
if (!patch) return;
const title = document.getElementById('patchEditTitle');
const amountSlider = document.getElementById('patchEditAmount');
const amountValue = document.getElementById('patchEditAmountValue');
const minSlider = document.getElementById('patchEditMin');
const minValue = document.getElementById('patchEditMinValue');
const maxSlider = document.getElementById('patchEditMax');
const maxValue = document.getElementById('patchEditMaxValue');
const applyBtn = document.getElementById('applyPatchEditButton');
const closeBtn = document.getElementById('closePatchEditModalButton');
title.textContent = `EDIT: ${patch.source} → ${patch.dest}`;
amountSlider.value = patch.amount;
amountValue.textContent = parseFloat(patch.amount).toFixed(2);
minSlider.value = patch.modRangeMin;
minValue.textContent = parseFloat(patch.modRangeMin).toFixed(2);
maxSlider.value = patch.modRangeMax;
maxValue.textContent = parseFloat(patch.modRangeMax).toFixed(2);
amountSlider.oninput = () => amountValue.textContent = parseFloat(amountSlider.value).toFixed(2);
minSlider.oninput = () => minValue.textContent = parseFloat(minSlider.value).toFixed(2);
maxSlider.oninput = () => maxValue.textContent = parseFloat(maxSlider.value).toFixed(2);
applyBtn.onclick = () => {
patch.amount = parseFloat(amountSlider.value);
patch.modRangeMin = parseFloat(minSlider.value);
patch.modRangeMax = parseFloat(maxSlider.value);
modal.style.display = 'none';
this.renderPatchesUI();
};
closeBtn.onclick = () => {
modal.style.display = 'none';
};
modal.style.display = 'flex';
}
renderPatchesUI() {
const c = document.getElementById('activePatches');
c.innerHTML = '';
this.patches.forEach(p => {
const e = document.createElement('div');
e.className = 'patch-instance';
const textSpan = document.createElement('span');
textSpan.textContent = `${p.source} → ${p.dest} (${p.amount})`;
e.appendChild(textSpan);
e.onclick = (event) => {
if (event.target.tagName !== 'BUTTON') {
this.openPatchEditor(p.id);
}
};
const b = document.createElement('button');
b.textContent = 'X';
b.onclick = (event) => {
event.stopPropagation();
this.removePatch(p.id);
};
e.appendChild(b);
c.appendChild(e);
});
}
createVoiceAnalyzer() {
const a = this.audioCtx.createAnalyser();
a.fftSize = 32;
a.smoothingTimeConstant = .5;
return {
analyzer: a,
data: new Uint8Array(a.frequencyBinCount)
};
}
setDistortionCurve(a) {
const k = a * 100,
n = 22050,
c = new Float32Array(n);
for (let i = 0; i < n; ++i) {
const x = i * 2 / n - 1;
c[i] = (Math.PI + k) * x / (Math.PI + k * Math.abs(x));
}
this.masterDistortion.curve = c;
}
createReverb(d) {
const c = this.audioCtx.createConvolver(),
l = this.audioCtx.sampleRate * d,
i = this.audioCtx.createBuffer(2, l, this.audioCtx.sampleRate);
for (let ch = 0; ch < 2; ch++) {
const dt = i.getChannelData(ch);
for (let j = 0; j < l; j++) dt[j] = (Math.random() * 2 - 1) * Math.pow(1 - j / l, 3);
}
c.buffer = i;
return c;
}
getAudioData() {
this.analyzer.getByteFrequencyData(this.dataArray);
return {
averageFrequency: this.dataArray.reduce((s, v) => s + v, 0) / this.dataArray.length
};
}
playKick(t, velocity = 1.0) {
const g = this.audioCtx.createGain();
g.connect(this.kickGain); // MODIFIED: Connect to dedicated kick gain
g.connect(this.modSources['BD ENV'].analyzer.analyzer);
const o = this.audioCtx.createOscillator(),
e = this.audioCtx.createGain(),
d = this.audioCtx.createWaveShaper();
this.setDistortionCurve.call({
masterDistortion: d
}, this.params.kickDrive);
o.connect(d);
d.connect(e);
e.connect(g);
o.frequency.setValueAtTime(this.params.kickPitch * 2, t);
o.frequency.exponentialRampToValueAtTime(this.params.kickPitch, t + .05);
e.gain.setValueAtTime(2.5 * velocity, t);
e.gain.exponentialRampToValueAtTime(.001, t + this.params.kickDecay);
o.start(t);
o.stop(t + this.params.kickDecay);
}
playSnare(t, velocity = 1.0) {
const g = this.audioCtx.createGain();
g.connect(this.snareGain); // MODIFIED: Connect to dedicated snare gain
g.connect(this.modSources['SD ENV'].analyzer.analyzer);
const n = this.audioCtx.createBufferSource(),
b = this.audioCtx.createBuffer(1, this.audioCtx.sampleRate * .2, this.audioCtx.sampleRate),
d = b.getChannelData(0);
for (let i = 0; i < d.length; i++) d[i] = Math.random() * 2 - 1;
n.buffer = b;
const f = this.audioCtx.createBiquadFilter();
f.type = 'highpass';
f.frequency.value = this.params.snareSnappy;
const ne = this.audioCtx.createGain();
n.connect(f);
f.connect(ne);
ne.connect(g);
const o = this.audioCtx.createOscillator();
o.type = 'triangle';
o.frequency.value = this.params.snareTone;
const oe = this.audioCtx.createGain();
o.connect(oe);
oe.connect(g);
ne.gain.setValueAtTime(1.5 * velocity, t);
ne.gain.exponentialRampToValueAtTime(.01, t + this.params.snareDecay);
oe.gain.setValueAtTime(0.7 * velocity, t);
oe.gain.exponentialRampToValueAtTime(.01, t + this.params.snareDecay * .5);
n.start(t);
n.stop(t + this.params.snareDecay);
o.start(t);
o.stop(t + this.params.snareDecay * .5);
}
playHat(t, velocity = 1.0) {
const r = [2, 3, 4.16, 5.43, 6.79, 8.21],
g = this.audioCtx.createGain();
g.gain.value = velocity;
g.connect(this.hatGain); // MODIFIED: Connect to dedicated hat gain
r.forEach(x => {
const o = this.audioCtx.createOscillator();
o.type = 'square';
o.frequency.value = this.params.hatMetal * x;
const e = this.audioCtx.createGain();
o.connect(e);
e.connect(g);
e.gain.setValueAtTime(1, t);
e.gain.exponentialRampToValueAtTime(.001, t + this.params.hatDecay);
o.start(t);
o.stop(t + this.params.hatDecay);
});
}
playBass(t, velocity = 1.0, stepIndex, meta = {}) {
const isWobbleStep = (meta.ratchet || 1) > 1;
const isAccent = meta.accent === true;
const prevStepIndex = (stepIndex - 1 + this.sequenceLength) % this.sequenceLength;
const prevStepIsActive = this.sequences.bass[prevStepIndex];
const prevStepMeta = this.stepMeta.bass[prevStepIndex] || {};
const prevStepWasWobble = prevStepIsActive && (prevStepMeta.ratchet || 1) > 1;
const o1 = this.audioCtx.createOscillator(),
o2 = this.audioCtx.createOscillator(),
e = this.audioCtx.createGain(),
f = this.audioCtx.createBiquadFilter();
f.type = 'lowpass';
const octaveOffset = isAccent ? -1 : 0;
const bf = 55 * Math.pow(2, this.params.bassOctave + octaveOffset);
o1.frequency.value = bf;
o2.frequency.value = bf;
o1.type = 'sine';
o2.type = 'sine';
o1.detune.value = -this.params.bassDetune;
o2.detune.value = this.params.bassDetune;
o1.connect(f);
o2.connect(f);
f.connect(e);
e.connect(this.bassGain); // MODIFIED: Connect to dedicated bass gain
if (isWobbleStep) {
// --- WOBBLE MODE ENGAGED ---
// Set a high resonance for that aggressive filter sound.
f.Q.value = 20;
// If this is the start of a new wobble chain, create/reset the LFO.
if (!prevStepWasWobble) {
if (this.bassLfo && this.bassLfo.lfo) {
this.bassLfo.lfo.stop(t);
}
const newLfo = this.audioCtx.createOscillator();
newLfo.type = 'sine'; // Sine is key for the "wub"
const newLfoGain = this.audioCtx.createGain();
this.bassLfo = {
lfo: newLfo,
lfoGain: newLfoGain
};
this.bassLfo.lfo.connect(this.bassLfo.lfoGain);
this.bassLfo.lfoGain.connect(f.frequency);
this.bassLfo.lfo.start(t);
}
// Bug Fix + Logic: Only proceed if the LFO is valid.
if (this.bassLfo) {
// Set LFO speed based on clamped ratchet value.
const clampedRatchet = Math.min(meta.ratchet, 4);
const lfoBaseRate = 2;
const lfoSpeed = lfoBaseRate * clampedRatchet;
this.bassLfo.lfo.frequency.setTargetAtTime(lfoSpeed, t, 0.01);
// --- NEW LOGIC: Filter slider now controls LFO DEPTH ---
// The base of the wobble is a very low frequency.
const filterBaseFreq = 100;
f.frequency.setValueAtTime(filterBaseFreq, t);
// The slider value (`this.params.bassFilterCutoff`) determines how far the LFO sweeps up from that base.
const lfoDepth = Math.max(0, this.params.bassFilterCutoff - filterBaseFreq);
this.bassLfo.lfoGain.gain.setTargetAtTime(lfoDepth, t, 0.01);
}
} else {
// --- NORMAL MODE ---
// Use the panel settings directly.
f.Q.value = this.params.bassFilterQ;
f.frequency.value = this.params.bassFilterCutoff;
// If the previous step was a wobble, we need to stop and clean up the LFO.
if (prevStepWasWobble) {
if (this.bassLfo && this.bassLfo.lfo) {
this.bassLfo.lfo.stop(t);
this.bassLfo = null;
}
}
}
const singleStepDur = (60 / this.bpm) / 4;
// Sustained note logic for longer wobbles.
let dur;
if (isWobbleStep && meta.ratchet >= 3) {
dur = singleStepDur * 2; // Lasts for two steps
} else {
dur = singleStepDur; // Lasts for one step
}
// Use a clean, gated envelope for wobbles to let the LFO do the talking.
if (isWobbleStep) {
e.gain.setValueAtTime(0, t);
e.gain.linearRampToValueAtTime(0.4 * velocity, t + 0.01);
e.gain.setValueAtTime(0.4 * velocity, t + dur * 0.98);
e.gain.linearRampToValueAtTime(0, t + dur);
} else {
// Original plucky envelope for standard notes.
e.gain.setValueAtTime(2.5, t);
e.gain.linearRampToValueAtTime(.4 * velocity, t + .01);
e.gain.setValueAtTime(.4 * velocity, t + dur * .9);
e.gain.linearRampToValueAtTime(0, t + dur);
}
o1.start(t);
o2.start(t);
o1.stop(t + dur);
o2.stop(t + dur);
}
playLyraNote(freq, time, accent = false, duration = null) {
this.lyraVoiceIndex = (this.lyraVoiceIndex + 1) % this.numLyraVoices;
const voice = this.lyraVoices[this.lyraVoiceIndex];
const p = this.lyraParams;
const now = time || this.audioCtx.currentTime;
const velocity = accent ? 1.5 : 1.0;
voice.isActive = true;
voice.noteFrequency = freq;
voice.osc1.frequency.setTargetAtTime(freq * Math.pow(2, p.osc1Oct), now, 0.001);
voice.osc1.detune.setTargetAtTime(-p.detune, now, 0.001);
voice.osc2.frequency.setTargetAtTime(freq * Math.pow(2, p.osc2Oct), now, 0.001);
voice.osc2.detune.setTargetAtTime(p.detune, now, 0.001);
voice.env.gain.cancelScheduledValues(now);
voice.env.gain.setTargetAtTime(velocity, now, parseFloat(p.attack) || 0.01);
voice.env.gain.setTargetAtTime(parseFloat(p.sustain) * velocity, now + parseFloat(p.attack), parseFloat(p.decay) || 0.01);
if (this.lyraSequencer.forceGate && duration) {
const gateReleaseTime = 0.01;
voice.env.gain.setTargetAtTime(0, now + duration, gateReleaseTime);
}
this.modSources['LYRA ENV'].value = voice.env.gain.value;
}
slideLyraNote(fromFreq, toFreq, time, accent = false, duration = null) {
let voice = this.lyraVoices.find(v => v.isActive && Math.abs(v.noteFrequency - fromFreq) < 1);
if (!voice) {
this.playLyraNote(toFreq, time, accent, duration);
return;
}
const p = this.lyraParams;
const now = time || this.audioCtx.currentTime;
const slideTime = p.portamento;
const velocity = accent ? 1.5 : 1.0;
voice.noteFrequency = toFreq;
voice.osc1.frequency.setTargetAtTime(toFreq * Math.pow(2, p.osc1Oct), now, slideTime);
voice.osc2.frequency.setTargetAtTime(toFreq * Math.pow(2, p.osc2Oct), now, slideTime);
voice.osc1.detune.setTargetAtTime(-p.detune, now, 0.001);
voice.osc2.detune.setTargetAtTime(p.detune, now, 0.001);
voice.env.gain.cancelScheduledValues(now);
if (accent) {
voice.env.gain.setTargetAtTime(velocity, now, parseFloat(p.attack) || 0.01);
voice.env.gain.setTargetAtTime(parseFloat(p.sustain) * velocity, now + parseFloat(p.attack), parseFloat(p.decay) || 0.01);
} else {
voice.env.gain.setTargetAtTime(parseFloat(p.sustain) * velocity, now, 0.001);
}
if (this.lyraSequencer.forceGate && duration) {
const gateReleaseTime = 0.01;
voice.env.gain.setTargetAtTime(0, now + duration, gateReleaseTime);
}
}
playLyraSequencerStep(stepIndex, time) {
const currentStepEl = document.getElementById(`lyraStep${stepIndex}`);
if (currentStepEl) {
const prevStepIndex = (stepIndex === 0) ? this.lyraSequencer.stepLength - 1 : stepIndex - 1;
const prevStepEl = document.getElementById(`lyraStep${prevStepIndex}`);
if (prevStepEl) prevStepEl.classList.remove('current');
currentStepEl.classList.add('current');
}
// This gating is now handled in the main scheduler to respect trig conditions
// const globalStepIndex = this.masterCycleStep % this.sequenceLength;
const stepIsGated = this.sequences.lyraGate[this.masterCycleStep % 32];
const probabilityCheck = Math.random() < this.lyraGlobalGateProbability;
if (!stepIsGated || !probabilityCheck) {
return;
}
const step = this.lyraSequencer.steps[stepIndex];
const baseStepDuration = 15.0 / this.bpm;
const gateDuration = step.gate ?? 1.0;
const noteDuration = !step.slide ? (baseStepDuration * gateDuration) : null;
if (!step.active) {
const prevStepIndex = (stepIndex > 0) ? stepIndex - 1 : this.lyraSequencer.stepLength - 1;
const prevStep = this.lyraSequencer.steps[prevStepIndex];
if (prevStep.active && prevStep.slide && this.lyraSequencer.lastPlayedFreq > 0) {
this.stopLyraNote(this.lyraSequencer.lastPlayedFreq, time);
}
return;
}
const midiNote = this.lyraSequencer.baseNote + step.note;
const freq = 440 * Math.pow(2, (midiNote - 69) / 12);
if (step.slide && this.lyraSequencer.lastPlayedFreq > 0) {
this.slideLyraNote(this.lyraSequencer.lastPlayedFreq, freq, time, step.accent, noteDuration);
} else {
this.playLyraNote(freq, time, step.accent, noteDuration);
}
this.lyraSequencer.lastPlayedFreq = freq;
}
stopLyraNote(freq, time) {
const p = this.lyraParams;
const now = time || this.audioCtx.currentTime;
const releaseTime = parseFloat(p.release) || 0.01;
this.lyraVoices.forEach(voice => {
if (voice.isActive && Math.abs(voice.noteFrequency - freq) < 1) {
voice.env.gain.cancelScheduledValues(now);
voice.env.gain.setTargetAtTime(0, now, releaseTime);
voice.isActive = false;
voice.noteFrequency = 0;
}
});
}
stopAllLyraNotes() {
const now = this.audioCtx.currentTime;
this.lyraVoices.forEach(voice => {
if (voice.isActive) {
voice.env.gain.cancelScheduledValues(now);
voice.env.gain.setTargetAtTime(0, now, 0.02);
voice.isActive = false;
voice.noteFrequency = 0;
}
});
}
handleLyraKey(code, isDown) {
if (this.audioCtx.state === "suspended") this.audioCtx.resume();
const keyMap = {
'KeyA': 0,
'KeyS': 2,
'KeyD': 3,
'KeyF': 5,
'KeyG': 7,
'KeyH': 8,
'KeyJ': 10,
'KeyK': 12,
'KeyL': 14
};
const noteNum = keyMap[code];
if (noteNum === undefined) return;
const freq = 130.81 * Math.pow(2, noteNum / 12);
if (isDown) {
this.playLyraNote(freq);
} else {
this.stopLyraNote(freq);
}
if (this.lyraLooper.isRecording) {
const eventTime = this.audioCtx.currentTime - this.lyraLooper.loopStartTime;
this.lyraLooper.notes.push({
type: isDown ? 'on' : 'off',
freq: freq,
time: eventTime
});
this.lyraLooper.loopDuration = Math.max(this.lyraLooper.loopDuration, eventTime);
}
}
toggleLyraRec() {
const l = this.lyraLooper;
l.isRecording = !l.isRecording;
const recBtn = document.getElementById('lyraRecButton'),
playBtn = document.getElementById('lyraPlayButton');
if (l.isRecording) {
if (l.isPlaying) this.toggleLyraPlay();
l.loopStartTime = this.audioCtx.currentTime;
playBtn.disabled = true;
} else {
if (l.notes.length > 0) {
l.loopDuration += 0.1;
l.notes.sort((a, b) => a.time - b.time);
}
playBtn.disabled = false;
}
recBtn.classList.toggle('recording', l.isRecording);
recBtn.textContent = l.isRecording ? 'STOP REC' : 'REC';
}
wipeLyraLoop() {
if (this.lyraLooper.isPlaying) this.toggleLyraPlay();
if (this.lyraLooper.isRecording) this.toggleLyraRec();
this.lyraLooper.notes = [];
this.lyraLooper.loopDuration = 0;
}
toggleLyraPlay() {
const l = this.lyraLooper;
if (l.isRecording) return;
l.isPlaying = !l.isPlaying;
const playBtn = document.getElementById('lyraPlayButton');
if (l.isPlaying) {
if (l.notes.length === 0 || l.loopDuration <= 0) {
l.isPlaying = false;
return;
}
l.nextNoteIndex = 0;
l.loopStartTime = this.audioCtx.currentTime;
this.lyraLooperTimerID = setInterval(this.scheduleLyraLoopNotes, 25);
} else {
if (this.lyraLooperTimerID) clearInterval(this.lyraLooperTimerID);
this.lyraLooperTimerID = null;
this.stopAllLyraNotes();
}
playBtn.classList.toggle('playing', l.isPlaying);
playBtn.textContent = l.isPlaying ? 'STOP' : 'PLAY';
}
scheduleLyraLoopNotes() {
if (!this.lyraLooper.isPlaying) return;
const lookahead = 0.1;
const loop = this.lyraLooper;
let scheduleUntil = this.audioCtx.currentTime + lookahead;
while (true) {
if (loop.nextNoteIndex >= loop.notes.length) {
loop.nextNoteIndex = 0;
loop.loopStartTime += loop.loopDuration;
}
const event = loop.notes[loop.nextNoteIndex];
const eventTime = loop.loopStartTime + event.time;
if (eventTime < scheduleUntil) {
if (event.type === 'on') {
this.playLyraNote(event.freq, eventTime);
} else {
this.stopLyraNote(event.freq, eventTime);
}
loop.nextNoteIndex++;
} else {
break;
}
}
}
// =======================================================================
// NEW: This function resets all modulated parameters to their UI values
// =======================================================================
resetAllModulatedParamsToRaw() {
const destinationToElementIdMap = {
'BD DECAY': 'kickDecay',
'BD DRIVE': 'kickDrive',
'BD PITCH': 'kickPitch',
'HHT DECAY': 'hatDecay',
'HHT METAL': 'hatMetal',
'SD SNAPPY': 'snareSnappy',
'SD TONE': 'snareTone',
'SNARE DECAY': 'snareDecay',
'BASS DETUNE': 'bassDetune',
'BASS FILTER': 'bassFilterCutoff',
'BASS OCTAVE': 'bassOctave',
'BASS RESO': 'bassFilterQ',
'MASTER DIST': 'masterDist',
'MASTER REVERB': 'masterReverb',
'LYRA DETUNE': 'lyraDetune',
'LYRA DRIVE': 'lyraDrive',
'LYRA FM AMT': 'lyraFMAmt',
'LYRA FOLD': 'lyraWaveFold',
'LYRA OSC1 OCT': 'lyraOsc1Oct',
'LYRA OSC2 OCT': 'lyraOsc2Oct',
'LYRA FILTER': 'lyraFilterCutoff',
'LYRA FILTER ENV': 'lyraFilterEnv',
'LYRA RESO': 'lyraFilterQ',
"LYRA ATTACK": 'lyraAttack',
"LYRA DECAY": 'lyraDecay',
"LYRA SUSTAIN": 'lyraSustain',
"LYRA RELEASE": 'lyraRelease',
'LYRA SLIDE': 'lyraPortamento',
'LYRA LFO DEPTH': 'lyraLfoDepth',
'LYRA LFO RATE': 'lyraLfoRate',
'LYRA DELAY FB': 'lyraDelayFeedback',
'LYRA DELAY MIX': 'lyraDelayMix',
'LYRA DELAY TIME': 'lyraDelayTime',
'PHASER DEPTH': 'phaserDepth',
'PHASER FEEDBACK': 'phaserFeedback',
'PHASER RATE': 'phaserRate',
'PHASER MIX': 'phaserMix',
'FLANGER DEPTH': 'flangerDepth',
'FLANGER RATE': 'flangerRate',
'FLANGER FEEDBACK': 'flangerFeedback',
'FLANGER MIX': 'flangerMix',
'QUAZAR FREQ': 'quazarFreq',
'QUAZAR RATE': 'quazarRate',
'QUAZAR RESO': 'quazarReso',
'QUAZAR MIX': 'quazarMix',
'CHOPPER DEPTH': 'chopperDepth',
'CRUSHER BITDEPTH': 'crusherBitDepth',
'CRUSHER DOWNSAMPLE': 'crusherFreq',
'CRUSHER MIX': 'crusherMix'
};
for (const destName in destinationToElementIdMap) {
const elementId = destinationToElementIdMap[destName];
const element = document.getElementById(elementId);
if (element) {
// Dispatching the 'input' event re-uses the existing event listeners
// to update the audio parameter with the slider's current value.
element.dispatchEvent(new Event('input', {
bubbles: true
}));
// Also dispatch 'change' for select elements
if (element.tagName === 'SELECT') {
element.dispatchEvent(new Event('change', {
bubbles: true
}));
}
}
}
}
updateModulation() {
// =======================================================================
// MODIFIED: Global modulation gate and freeze checks
// =======================================================================
if (this.isMuted) {
requestAnimationFrame(this.updateModulation);
return;
}
if (this.modsFrozen) {
requestAnimationFrame(this.updateModulation);
return;
}
// =======================================================================
if (!this.isPlaying && !this.lyraVoices.some(v => v.isActive) && !this.lyraSequencer.isPlaying) {
requestAnimationFrame(this.updateModulation);
return;
}
const l1 = this.modSources['LFO 1 (Slow)'];
l1.phase += 0.05 * (this.bpm / 120);
l1.value = Math.sin(l1.phase * 0.1);
const l2 = this.modSources['LFO 2 (Fast)'];
l2.phase += 0.2 * (this.bpm / 120);
l2.value = Math.sin(l2.phase);
const l3 = this.modSources['LFO 1 (Slower)'];
l3.phase += l3.rate * (this.bpm / 120);
l3.value = Math.sin(l3.phase);
const l4 = this.modSources['LFO 2 (Faster)'];
l4.phase += l4.rate * (this.bpm / 120);
l4.value = Math.sin(l4.phase);
const m1 = this.modSources['LFO 1 (Slow)'].value;
const m2 = this.modSources['LFO 2 (Fast)'].value;
const m3 = this.modSources['LFO 1 (Slower)'].value;
const m4 = this.modSources['LFO 2 (Faster)'].value;
const mix = (m1 * 0.1 + m2 * 0.2 + m3 * 0.4 + m4 * 0.3);
this.modSources['LFO MIX'].value = mix;
this.modSources['S&H (Random)'].value = Math.random() * 2 - 1;
for (let i = 1; i <= 5; i++) {
const p = Math.pow(2, i);
this.modSources[`CLK/${p}`].value = (this.masterCycleStep % p < p / 2) ? 1 : -1;
}
for (const k of ['BD ENV', 'SD ENV']) {
const s = this.modSources[k];
s.analyzer.analyzer.getByteTimeDomainData(s.analyzer.data);
s.value = s.analyzer.data.reduce((a, v) => a + Math.abs(v - 128), 0) / s.analyzer.data.length / 128;
}
this.modSources['LYRA LFO'].value = Math.sin(this.audioCtx.currentTime * this.lyraLfoNode.frequency.value * 2 * Math.PI);
const activeModDestinations = this.isSafetyOn ?
this.modDestinationsSafe :
this.modDestinationsUnsafe;
// =======================================================================
// MODIFIED: Incorporate global modulation scale
// =======================================================================
const scale = this.globalModScale;
// =======================================================================
this.patches.forEach(p => {
const s = this.modSources[p.source];
const d = activeModDestinations[p.dest];
if (s && d) {
let sourceValue = p.isLocked ? p.lockedValue : s.value;
const normalizedValue = (sourceValue + 1) / 2;
const scaledValue = normalizedValue * (p.modRangeMax - p.modRangeMin) + p.modRangeMin;
// =======================================================================
// MODIFIED: Apply the global scale to the final output
// =======================================================================
d(scaledValue * p.amount * scale);
// =======================================================================
}
});
requestAnimationFrame(this.updateModulation);
}
scheduler() {
if (!this.isPlaying) return;
while (this.nextNoteTime < this.audioCtx.currentTime + this.scheduleAheadTime) {
const currentStepInPattern = this.masterCycleStep % 32;
const currentPass = (this.masterCycleStep < 32) ? 'first' : 'second';
// Sample the main random source's current value
const randomSourceValue = this.modSources['S&H (Random)'].value;
// Update the stepped S&H sources if the step is a multiple of the interval
if (this.masterCycleStep % 2 === 0) this.modSources['S&H (2 steps)'].value = randomSourceValue;
if (this.masterCycleStep % 4 === 0) this.modSources['S&H (4 steps)'].value = randomSourceValue;
if (this.masterCycleStep % 8 === 0) this.modSources['S&H (8 steps)'].value = randomSourceValue;
if (this.masterCycleStep % 16 === 0) this.modSources['S&H (16 steps)'].value = randomSourceValue;
if (this.masterCycleStep % 32 === 0) this.modSources['S&H (32 steps)'].value = randomSourceValue;
if (this.masterCycleStep % 64 === 0) this.modSources['S&H (64 steps)'].value = randomSourceValue;
const noteTime = this.nextNoteTime;
const stepDuration = 15.0 / this.bpm;
['kick', 'snare', 'hat', 'bass'].forEach(type => {
if (this.sequences[type][currentStepInPattern]) {
const meta = (this.stepMeta[type] && this.stepMeta[type][currentStepInPattern]) ? this.stepMeta[type][currentStepInPattern] : {
velocity: 1,
prob: 1,
ratchet: 1
};
const condition = meta.triggerPass || 'always';
if (condition === 'always' || condition === currentPass) {
if (Math.random() < (meta.prob ?? 1.0)) {
const ratchetCount = (type === 'bass' && meta.ratchet > 1) ? 1 : Math.max(1, meta.ratchet ?? 1);
const ratchetInterval = stepDuration / ratchetCount;
let swingDelay = 0;
if (type === 'hat' && this.swing > 0 && currentStepInPattern % 2 !== 0) {
swingDelay = stepDuration * this.swing;
}
for (let i = 0; i < ratchetCount; i++) {
const rachetTime = noteTime + (i * ratchetInterval) + swingDelay;
const velocity = meta.velocity ?? 1.0;
switch (type) {
case 'kick':
this.playKick(rachetTime, velocity);
break;
case 'snare':
this.playSnare(rachetTime, velocity);
break;
case 'hat':
this.playHat(rachetTime, velocity);
break;
case 'bass':
this.playBass(rachetTime, velocity, currentStepInPattern, meta);
break;
}
}
}
}
}
});
if (this.lyraSequencer.isPlaying) {
const lyraStep = currentStepInPattern % this.lyraSequencer.stepLength;
const lyraGateMeta = (this.stepMeta.lyraGate && this.stepMeta.lyraGate[currentStepInPattern]) || {};
const lyraGateCondition = lyraGateMeta.triggerPass || 'always';
if (lyraGateCondition === 'always' || lyraGateCondition === currentPass) {
this.playLyraSequencerStep(lyraStep, noteTime);
}
}
this.nextNoteTime += stepDuration;
this.masterCycleStep = (this.masterCycleStep + 1) % 64;
}
this.updateLooperDisplay();
this.timerID = setTimeout(this.scheduler, 25);
}
nudgePattern(type, direction) {
const seq = this.sequences[type];
const meta = this.stepMeta[type];
const len = this.sequenceLength;
// Nudge sequence
if (direction > 0) { // Nudge Right
seq.unshift(seq.pop());
} else { // Nudge Left
seq.push(seq.shift());
}
// Nudge Meta
const newMeta = {};
for (const key in meta) {
let newKey = (parseInt(key) + direction + len) % len;
newMeta[newKey] = meta[key];
}
this.stepMeta[type] = newMeta;
this.updatePatternEditor();
}
duplicatePattern(type, factor) {
const seq = this.sequences[type];
const meta = this.stepMeta[type];
const segmentLength = this.sequenceLength / factor;
const seqSegment = seq.slice(0, segmentLength);
const metaSegment = {};
for (let i = 0; i < segmentLength; i++) {
if (meta[i]) {
metaSegment[i] = meta[i];
}
}
for (let i = 0; i < this.sequenceLength; i++) {
const sourceIndex = i % segmentLength;
seq[i] = seqSegment[sourceIndex];
if (metaSegment[sourceIndex]) {
meta[i] = JSON.parse(JSON.stringify(metaSegment[sourceIndex]));
} else {
delete meta[i];
}
}
this.updatePatternEditor();
}
toggleLyraSequencer() {
this.lyraSequencer.isPlaying = !this.lyraSequencer.isPlaying;
const btn = document.getElementById('lyraSeqToggle');
btn.classList.toggle('active', this.lyraSequencer.isPlaying);
btn.textContent = this.lyraSequencer.isPlaying ? 'SEQ ON' : 'SEQ OFF';
if (!this.lyraSequencer.isPlaying) {
document.querySelectorAll('.lyra-step.current').forEach(el => el.classList.remove('current'));
this.stopAllLyraNotes();
}
}
randomizeLyraSequence() {
const notes = [0, 2, 3, 5, 7, 8, 10, 12, 14, 15];
for (let i = 0; i < this.lyraSequencer.stepLength; i++) {
this.lyraSequencer.steps[i] = {
active: Math.random() > 0.4,
note: notes[Math.floor(Math.random() * notes.length)],
slide: Math.random() > 0.7,
accent: Math.random() > 0.8,
gate: 1.0
};
}
this.lyraSequencer.steps[0].active = true;
this.lyraSequencer.steps[0].accent = true;
this.updateLyraSequencerUI();
}
updateLyraSequencerUI() {
for (let i = 0; i < this.lyraSequencer.stepLength; i++) {
const step = this.lyraSequencer.steps[i];
const stepEl = document.querySelector(`#lyraStep${i}`);
if (stepEl) {
stepEl.classList.toggle('active', step.active);
stepEl.classList.toggle('slide', step.slide);
stepEl.classList.toggle('accent', step.accent);
stepEl.textContent = step.active ? ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B', 'C+', 'C#+', 'D+', 'D#+', 'E+', 'F+', 'F#+', 'G+', 'G#+', 'A+', 'A#+', 'B+', 'C++'][step.note] : '—';
}
}
}
randomizeLyraSettings() {
const randomPick = arr => arr[Math.floor(Math.random() * arr.length)];
const randomRange = (min, max, step = 0.01) => parseFloat((Math.random() * (max - min) + min).toFixed(3));
const r = {
osc1Type: randomPick(['sine', 'square', 'sawtooth', 'triangle']),
osc1Oct: Math.floor(randomRange(-2, 2.99, 1)),
osc2Type: randomPick(['sine', 'square', 'sawtooth', 'triangle']),
osc2Oct: Math.floor(randomRange(-2, 2.99, 1)),
detune: randomRange(0, 50),
fmAmount: randomRange(0, 200),
waveFold: randomRange(0, 1),
drive: randomRange(0, 1),
driveMode: randomPick(['soft clip', 'hard clip', 'exponential', 'wavefold']),
filterType: randomPick(['lowpass', 'notch']),
filterCutoff: randomRange(2000, 15000),
filterQ: randomRange(0.1, 18),
filterEnv: randomRange(-500, 500),
lfoRate: randomRange(0.1, 20),
lfoDepth: randomRange(10, 500),
attack: randomRange(0.01, 0.2),
decay: randomRange(0.01, 0.3),
sustain: randomRange(0, 0.02),
release: randomRange(0.01, 0.2),
portamento: randomRange(0, 200)
};
for (const [k, v] of Object.entries(r)) {
this.updateLyraParam(k, k === 'portamento' ? v / 1000 : v);
const el = document.getElementById('lyra' + k.charAt(0).toUpperCase() + k.slice(1));
if (el) el.value = v;
}
this.updateLyraParam('all');
}
updateLooperDisplay() {
const masterStep = this.masterCycleStep;
const currentStepInPattern = masterStep % 32;
const currentPass = (masterStep < 32) ? 'first' : 'second';
document.querySelectorAll('.pattern-row .step.current').forEach(e => e.classList.remove('current'));
document.querySelectorAll(`.pattern-row .step[data-index='${currentStepInPattern}']`).forEach(e => e.classList.add('current'));
for (const type in this.sequences) {
const container = document.querySelector(`.pattern-row[data-type="${type}"]`);
if (!container) continue;
container.querySelectorAll('.step').forEach((stepEl, i) => {
if (!this.sequences[type][i]) {
stepEl.classList.remove('flashing-inactive');
return;
};
const meta = (this.stepMeta[type] && this.stepMeta[type][i]) || {};
const condition = meta.triggerPass || 'always';
if (condition === 'always') {
stepEl.classList.remove('flashing-inactive');
} else if (condition === 'first') {
stepEl.classList.toggle('flashing-inactive', currentPass === 'second');
} else if (condition === 'second') {
stepEl.classList.toggle('flashing-inactive', currentPass === 'first');
}
});
}
}
start() {
if (this.isPlaying) return;
if (this.audioCtx.state === "suspended") this.audioCtx.resume();
this.isPlaying = true;
this.masterCycleStep = 0;
this.nextNoteTime = this.audioCtx.currentTime + .05;
this.scheduler();
requestAnimationFrame(this.updateModulation);
document.getElementById("playButton").textContent = "HALT ❚❚";
document.getElementById("playButton").classList.add("active");
}
panic() {
const now = this.audioCtx.currentTime;
// 1. Stop all scheduled Lyra notes and ramp them down
this.stopAllLyraNotes();
// 2. Clear any pending notes from the looper
if (this.lyraLooper.isPlaying) {
this.toggleLyraPlay(); // This already handles stopping and cleaning up the timer
}
// 3. Explicitly ramp down the master gain as a final failsafe.
// This catches any stray sounds from drum hits that were already scheduled.
const masterGainNode = this.masterGain;
masterGainNode.gain.cancelScheduledValues(now);
masterGainNode.gain.setTargetAtTime(0, now, 0.015); // Quick fade to zero to prevent clicks
// 4. Restore the gain after the fade so it's ready for the next play
masterGainNode.gain.setTargetAtTime(0.4, now + 0.02, 0.01); // Restore to default value (0.4)
// 5. Reset any modulations that are locked to prevent stuck values on next play
this.patches.forEach(patch => {
if (patch.isLocked) {
// Optional: You could unlock them all here, or just reset the destination parameter
const destFunction = this.modDestinationsSafe[patch.dest]; // Use safe version for lookup
const controlElement = document.getElementById(Object.keys(this.modDestinationsSafe).find(key => this.modDestinationsSafe[key] === destFunction));
// This is a simplified reset; a more robust version might
// store default values for each parameter. For now, we rely on the UI.
if (controlElement) {
// This is a basic attempt to reset the parameter
const defaultValue = controlElement.defaultValue || 0.5;
destFunction(defaultValue);
}
}
});
console.log("Audio engine panic: All sounds stopped.");
}
stop() {
if (!this.isPlaying) return;
this.isPlaying = false;
clearTimeout(this.timerID);
if (this.bassLfo && this.bassLfo.lfo) {
this.bassLfo.lfo.stop();
this.bassLfo = null;
}
this.panic(); // <-- CALL THE PANIC METHOD HERE
document.getElementById("playButton").textContent = "PLAY ▶";
document.getElementById("playButton").classList.remove("active");
document.querySelectorAll('.pattern-row .step.current').forEach(el => el.classList.remove('current'));
document.querySelectorAll('.pattern-row .step.flashing-inactive').forEach(el => el.classList.remove('flashing-inactive'));
}
togglePlay() {
this.isPlaying ? this.stop() : this.start();
}
setBPM(v) {
this.bpm = parseInt(v);
if (this.lyraFxNodes.chopper) {
const rateMultiplier = parseFloat(document.getElementById('chopperRate').value);
this.lyraFxNodes.chopper.lfo.frequency.setTargetAtTime((this.bpm / 60) * rateMultiplier, this.audioCtx.currentTime, 0.01);
}
this.updateLyraParam('lfoRate');
}
randomizeSequences() {
const k = Array(this.sequenceLength).fill(false),
s = Array(this.sequenceLength).fill(false);
s[4] = true;
s[12] = true;
s[20] = true;
s[28] = true;
if (Math.random() < .15) s[12] = false;
if (Math.random() < .15) s[28] = false;
if (this.bpm < 135) {
k[0] = true;
k[8] = true;
k[16] = true;
k[24] = true;
if (Math.random() > .6) k[6] = true;
if (Math.random() > .5) k[14] = true;
if (Math.random() > .7) k[22] = true;
if (Math.random() > .4) k[30] = true;
} else {
const p = Math.random();
if (p < .7) {
k[0] = true;
k[8] = true;
k[16] = true;
k[24] = true;
if (Math.random() > .4) k[3] = true;
if (Math.random() > .6) k[13] = true;
if (Math.random() > .4) k[19] = true;
if (Math.random() > .6) k[29] = true;
} else {
k[0] = true;
if (Math.random() > .2) k[6] = true;
k[11] = true;
k[16] = true;
if (Math.random() > .3) k[22] = true;
k[27] = true;
}
}
this.sequences.kick = k;
this.sequences.snare = s;
this.sequences.hat = this.sequences.hat.map(() => Math.random() > .4);
this.sequences.bass = this.sequences.bass.map(() => Math.random() > .7);
this.sequences.lyraGate = this.sequences.lyraGate.map(() => Math.random() > .2);
this.stepMeta = {
kick: {},
snare: {},
hat: {},
bass: {},
lyraGate: {}
};
this.updatePatternEditor();
}
updatePatternEditor() {
for (const type in this.sequences) {
const container = document.querySelector(`.pattern-row[data-type="${type}"]`);
if (!container) continue;
container.querySelectorAll('.step').forEach((stepEl, i) => {
const isActive = this.sequences[type][i];
stepEl.classList.toggle('active', isActive);
stepEl.classList.remove('trig-first', 'trig-second', 'accent');
if (isActive) {
const meta = (this.stepMeta[type] && this.stepMeta[type][i]) || {};
const velocity = meta.velocity ?? 1.0;
const ratchet = meta.ratchet ?? 1;
const condition = meta.triggerPass || 'always';
const prob = meta.prob ?? 1.0;
const isAccent = meta.accent === true;
stepEl.style.opacity = 0.4 + (velocity * 0.6);
stepEl.textContent = ratchet > 1 ? ratchet : '';
if (condition === 'first') {
stepEl.classList.add('trig-first');
} else if (condition === 'second') {
stepEl.classList.add('trig-second');
}
stepEl.classList.toggle('has-prob', prob < 1.0);
stepEl.classList.toggle('accent', isAccent);
} else {
stepEl.style.opacity = 1;
stepEl.textContent = '';
stepEl.classList.remove('flashing-inactive');
stepEl.classList.remove('has-prob');
}
});
}
}
getState() {
const state = {
controls: [],
patches: this.patches.map(p => ({
id: p.id,
source: p.source,
dest: p.dest,
amount: p.amount,
isLocked: p.isLocked,
...(p.isLocked && {
lockedValue: p.lockedValue
}),
modRangeMin: p.modRangeMin,
modRangeMax: p.modRangeMax
})),
isSafetyOn: this.isSafetyOn,
};
document.querySelectorAll('#controls [id]').forEach(el => {
if (el.tagName === 'INPUT' || el.tagName === 'SELECT') {
const value = el.type === 'checkbox' ? el.checked : el.value;
state.controls.push({
id: el.id,
value: value
});
}
if (el.id === 'lyraLfoSyncToggle' && el.classList.contains('active')) {
state.controls.push({
id: el.id,
value: true
});
}
});
state.sequences = this.sequences;
state.lyraSequencer = {
isPlaying: this.lyraSequencer.isPlaying,
steps: this.lyraSequencer.steps
};
state.stepMeta = this.stepMeta;
state.version = this.presetVersion;
return state;
}
setState(state) {
this.reloadUntil = performance.now() + 600;
this.isSafetyOn = state.isSafetyOn ?? false; // Default to false for old presets
const safetyBtn = document.getElementById('safetyToggle');
if (safetyBtn) {
safetyBtn.classList.toggle('active', this.isSafetyOn);
safetyBtn.textContent = this.isSafetyOn ? 'SAFETY: ON' : 'SAFETY: OFF';
}
const driveModeControl = state.controls.find(c => c.id === 'lyraDriveMode');
if (!driveModeControl) {
this.lyraParams.driveMode = 'soft clip';
const driveModeSelect = document.getElementById('lyraDriveMode');
if (driveModeSelect) {
driveModeSelect.value = 'soft clip';
}
}
const syncToggleState = state.controls.find(c => c.id === 'lyraLfoSyncToggle');
if (syncToggleState && syncToggleState.value) {
document.getElementById('lyraLfoSyncToggle').classList.add('active');
document.getElementById('lyraLfoRate').style.display = 'none';
document.getElementById('lyraLfoSyncRate').style.display = 'block';
} else {
document.getElementById('lyraLfoSyncToggle').classList.remove('active');
document.getElementById('lyraLfoRate').style.display = 'block';
document.getElementById('lyraLfoSyncRate').style.display = 'none';
}
state.controls.forEach(c => {
if (c.id === 'lyraLfoSyncToggle') return;
const el = document.getElementById(c.id);
if (el) {
if (el.type === 'checkbox') el.checked = c.value;
else el.value = c.value;
el.dispatchEvent(new Event('input', {
bubbles: true
}));
el.dispatchEvent(new Event('change', {
bubbles: true
}));
}
});
if (state.sequences) {
const defaultSequences = {
kick: Array(32).fill(false),
snare: Array(32).fill(false),
hat: Array(32).fill(false),
bass: Array(32).fill(false),
lyraGate: Array(32).fill(true)
};
this.sequences = {
...defaultSequences,
...state.sequences
};
}
if (state.version && state.version >= 2) {
this.stepMeta = state.stepMeta || {
kick: {},
snare: {},
hat: {},
bass: {},
lyraGate: {}
};
} else {
this.stepMeta = {
kick: {},
snare: {},
hat: {},
bass: {},
lyraGate: {}
};
}
this.updatePatternEditor();
if (state.lyraSequencer) {
this.lyraSequencer.steps = state.lyraSequencer.steps;
this.lyraSequencer.isPlaying = state.lyraSequencer.isPlaying || false;
this.updateLyraSequencerUI();
const seqBtn = document.getElementById('lyraSeqToggle');
if (seqBtn) {
seqBtn.classList.toggle('active', this.lyraSequencer.isPlaying);
seqBtn.textContent = this.lyraSequencer.isPlaying ? 'SEQ ON' : 'SEQ OFF';
}
}
if (state.patches && Array.isArray(state.patches)) {
this.patches = state.patches.map(p => ({
...p,
id: p.id || crypto.randomUUID(),
isLocked: p.isLocked || false,
lockedValue: p.lockedValue || 0,
modRangeMin: p.modRangeMin ?? -1.0,
modRangeMax: p.modRangeMax ?? 1.0
}));
} else {
this.patches = [];
}
this.renderPatchesUI();
}
savePreset(name) {
if (!name) {
alert("Please enter a preset name.");
return;
}
const state = this.getState();
localStorage.setItem(`pulsar_lyra_preset_${name}`, JSON.stringify(state));
this.populatePresets();
}
loadPreset(name) {
if (this.isPlaying) {
this.stop();
}
this.panic();
const stateJSON = localStorage.getItem(`pulsar_lyra_preset_${name}`);
if (stateJSON) {
this.setState(JSON.parse(stateJSON));
} else {
alert(`Preset "${name}" not found.`);
}
}
deletePreset(name) {
if (confirm(`Are you sure you want to delete preset "${name}"?`)) {
localStorage.removeItem(`pulsar_lyra_preset_${name}`);
this.populatePresets();
return true;
}
return false;
}
populatePresets() {
const select = document.getElementById('presetSelect');
select.innerHTML = '';
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i);
if (key.startsWith('pulsar_lyra_preset_')) {
const name = key.replace('pulsar_lyra_preset_', '');
const option = document.createElement('option');
option.value = name;
option.textContent = name;
select.appendChild(option);
}
}
}
exportAllPresets() {
const allPresets = {};
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i);
if (key.startsWith('pulsar_lyra_preset_')) {
const name = key.replace('pulsar_lyra_preset_', '');
allPresets[name] = JSON.parse(localStorage.getItem(key));
}
}
const blob = new Blob([JSON.stringify(allPresets, null, 2)], {
type: 'application/json'
});
const url = URL.createObjectURL(blob);
const a = Object.assign(document.createElement('a'), {
href: url,
download: `pulsar-lyra-presets-${new Date().toISOString().slice(0,10)}.json`
});
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
}
}
class VisualSystem {
constructor(c, a) {
this.audioSystem = a;
this.scene = new THREE.Scene();
this.camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, .1, 1000);
this.camera.position.z = 4;
this.renderer = new THREE.WebGLRenderer({
antialias: true,
alpha: true
});
this.renderer.setSize(window.innerWidth, window.innerHeight);
c.appendChild(this.renderer.domElement);
this.uniforms = {
time: {
value: 0
},
audioLevel: {
value: 0
}
};
const g = new THREE.SphereGeometry(2.5, 250, 250);
const m = new THREE.ShaderMaterial({
uniforms: this.uniforms,
vertexShader: `
uniform float time;
uniform float audioLevel;
varying vec3 vNormal;
void main() {
vNormal = normal;
vec3 pos = position + normal * sin(time * 4.0 + position.y * 12.0) * audioLevel * 0.9;
gl_Position = projectionMatrix * modelViewMatrix * vec4(pos, 1.0);
}
`,
fragmentShader: `
varying vec3 vNormal;
uniform float audioLevel;
void main() {
float light = dot(normalize(vNormal), normalize(vec3(0.5, 0.2, 1.0)));
vec3 starColor = vec3(1.0, 1.0, 1.0);
vec3 glowColor = vec3(0.3, 0.6, 1.0);
float glow = pow(light, 4.0) + audioLevel * 2.5;
vec3 finalColor = mix(starColor, glowColor, glow);
gl_FragColor = vec4(finalColor * (0.5 + 0.5 * light + audioLevel), 1.0);
}
`,
transparent: true,
wireframe: false
});
this.mesh = new THREE.Mesh(g, m);
this.scene.add(this.mesh);
this.animate = this.animate.bind(this);
requestAnimationFrame(this.animate);
window.addEventListener('resize', this.onWindowResize.bind(this), false)
}
onWindowResize() {
this.camera.aspect = window.innerWidth / window.innerHeight;
this.camera.updateProjectionMatrix();
this.renderer.setSize(window.innerWidth, window.innerHeight)
}
animate(t) {
requestAnimationFrame(this.animate);
const n = t * .001;
this.uniforms.time.value = n;
const a = this.audioSystem.getAudioData();
this.uniforms.audioLevel.value = a.averageFrequency / 255;
this.mesh.rotation.x = n * .1;
this.mesh.rotation.y = n * .2;
this.renderer.render(this.scene, this.camera)
}
}
class App {
constructor() {
this.audioSystem = new AudioSystem();
this.visualSystem = new VisualSystem(document.getElementById("canvas-container"), this.audioSystem);
this.eventHandlers = []; // For cleanup
this.init();
}
dispose() {
try {
// remove all attached listeners
if (this.eventHandlers && Array.isArray(this.eventHandlers)) {
for (const h of this.eventHandlers) {
h.element.removeEventListener(h.event, h.handler, h.options);
}
this.eventHandlers.length = 0;
}
// stop audio and timers
if (this.audioSystem) {
this.audioSystem.stop();
if (this.audioSystem.lyraLooperTimerID) {
clearInterval(this.audioSystem.lyraLooperTimerID);
this.audioSystem.lyraLooperTimerID = null;
}
if (this.audioSystem.timerID) {
clearTimeout(this.audioSystem.timerID);
this.audioSystem.timerID = null;
}
}
// release WebGL
if (this.visualSystem && this.visualSystem.renderer) {
const r = this.visualSystem.renderer;
const gl = r.getContext();
if (gl && gl.getExtension) {
const ext = gl.getExtension("WEBGL_lose_context");
if (ext && ext.loseContext) ext.loseContext();
}
r.dispose();
}
} catch (e) {
console.error("dispose error", e);
}
}
async init() {
await this.audioSystem.initFx();
this.setupControls();
this.installDefaultPresets();
this.audioSystem.populatePresets();
this.audioSystem.loadPreset('white dwarf');
window.addEventListener("beforeunload", () => this.dispose());
}
installDefaultPresets() {
const myPreset = {
"controls": [{
"id": "bpm",
"value": "138"
}, {
"id": "presetSelect",
"value": "white dwarf"
}, {
"id": "presetNameInput",
"value": ""
}, {
"id": "lyraPortamento",
"value": "80"
}, {
"id": "lyraOsc1Type",
"value": "sine"
}, {
"id": "lyraOsc1Oct",
"value": "1"
}, {
"id": "lyraOsc2Type",
"value": "sine"
}, {
"id": "lyraOsc2Oct",
"value": "0"
}, {
"id": "lyraDetune",
"value": "3"
}, {
"id": "lyraFMAmt",
"value": "256"
}, {
"id": "lyraWaveFold",
"value": "0.03"
}, {
"id": "lyraDrive",
"value": "0.04"
}, {
"id": "lyraDriveMode",
"value": "soft clip"
}, {
"id": "lyraFilterType",
"value": "lowpass"
}, {
"id": "lyraFilterCutoff",
"value": "3845"
}, {
"id": "lyraFilterQ",
"value": "11.1"
}, {
"id": "lyraFilterEnv",
"value": "-752"
}, {
"id": "lyraLfoRate",
"value": "0.3"
}, {
"id": "lyraLfoSyncRate",
"value": "0.25"
}, {
"id": "lyraLfoSyncToggle",
"value": true
}, {
"id": "lyraLfoDepth",
"value": "1524"
}, {
"id": "lyraAttack",
"value": "0.09"
}, {
"id": "lyraDecay",
"value": "0.05"
}, {
"id": "lyraSustain",
"value": "0.06"
}, {
"id": "lyraRelease",
"value": "0.25"
}, {
"id": "lyraFxType",
"value": "Chopper"
}, {
"id": "lyraDelayTime",
"value": "0.45"
}, {
"id": "lyraDelayFeedback",
"value": "0.2"
}, {
"id": "lyraDelayMix",
"value": "0.35"
}, {
"id": "phaserRate",
"value": "0.5"
}, {
"id": "phaserDepth",
"value": "1200"
}, {
"id": "phaserFeedback",
"value": "0.3"
}, {
"id": "phaserMix",
"value": "0.5"
}, {
"id": "flangerRate",
"value": "0.2"
}, {
"id": "flangerDepth",
"value": "0.005"
}, {
"id": "flangerFeedback",
"value": "0.5"
}, {
"id": "flangerMix",
"value": "0.5"
}, {
"id": "quazarRate",
"value": "2"
}, {
"id": "quazarReso",
"value": "25"
}, {
"id": "quazarFreq",
"value": "1500"
}, {
"id": "quazarMix",
"value": "1"
}, {
"id": "chopperRate",
"value": "2"
}, {
"id": "chopperDepth",
"value": "0.05"
}, {
"id": "crusherBitDepth",
"value": "8"
}, {
"id": "crusherFreq",
"value": "0.5"
}, {
"id": "crusherMix",
"value": "0.5"
}, {
"id": "patchSource",
"value": "KAOS X"
}, {
"id": "patchDest",
"value": "BD PITCH"
}, {
"id": "patchAmount",
"value": "1.2"
}, {
"id": "kickPitch",
"value": "51"
}, {
"id": "kickDecay",
"value": "0.17"
}, {
"id": "kickDrive",
"value": "0.39"
}, {
"id": "bassOctave",
"value": "-1"
}, {
"id": "bassDetune",
"value": "13"
}, {
"id": "bassFilterCutoff",
"value": "2265"
}, {
"id": "bassFilterQ",
"value": "17"
}, {
"id": "snareTone",
"value": "129"
}, {
"id": "snareSnappy",
"value": "5245"
}, {
"id": "snareDecay",
"value": "0.25"
}, {
"id": "hatDecay",
"value": "0.16"
}, {
"id": "hatMetal",
"value": "7571"
}, {
"id": "masterDist",
"value": "0.05"
}, {
"id": "masterReverb",
"value": "0.44"
}],
"patches": [{
"source": "S&H (Random)",
"dest": "HHT METAL",
"amount": 0.5
}, {
"source": "LFO 2 (Fast)",
"dest": "BASS OCTAVE",
"amount": 0.5
}, {
"source": "KAOS X",
"dest": "LYRA GATE PROB",
"amount": 0.5
}, {
"source": "KAOS Y",
"dest": "LYRA DELAY TIME",
"amount": 1.3
}, {
"source": "LFO 2 (Fast)",
"dest": "SD SNAPPY",
"amount": 1.3
}, {
"source": "LYRA LFO",
"dest": "LYRA RESO",
"amount": 1.3
}],
"sequences": {
"kick": [true, false, false, false, false, false, false, false, true, false, true, false, false, false, false, false, true, false, false, false, false, false, false, false, false, false, true, false, false, false, false, false],
"snare": [false, false, false, false, true, false, false, false, false, false, false, false, true, false, false, false, false, false, false, false, true, false, false, false, false, false, false, false, true, false, false, false],
"hat": [false, true, true, false, true, true, false, false, true, true, true, true, false, false, false, true, true, true, true, true, false, false, false, false, false, true, true, true, true, true, true, true],
"bass": [false, false, true, false, true, false, true, false, false, false, true, true, false, false, true, true, false, false, true, false, true, false, false, true, false, false, true, true, false, false, false, false],
"lyraGate": [true, true, true, true, true, true, true, true, true, true, true, true, true, true, false, true, true, true, true, true, true, true, true, true, true, true, true, false, true, true, true, false]
},
"lyraSequencer": {
"isPlaying": true,
"steps": [{
"active": true,
"note": 3,
"slide": false,
"accent": true
}, {
"active": true,
"note": 8,
"slide": false,
"accent": true
}, {
"active": true,
"note": 0,
"slide": true,
"accent": true
}, {
"active": true,
"note": 10,
"slide": false,
"accent": true
}, {
"active": false,
"note": 0,
"slide": false,
"accent": false
}, {
"active": true,
"note": 5,
"slide": true,
"accent": true
}, {
"active": true,
"note": 14,
"slide": true,
"accent": true
}, {
"active": true,
"note": 2,
"slide": false,
"accent": true
}, {
"active": true,
"note": 3,
"slide": false,
"accent": true
}, {
"active": true,
"note": 14,
"slide": false,
"accent": true
}, {
"active": true,
"note": 15,
"slide": false,
"accent": true
}, {
"active": true,
"note": 5,
"slide": false,
"accent": true
}, {
"active": false,
"note": 0,
"slide": false,
"accent": false
}, {
"active": true,
"note": 7,
"slide": false,
"accent": true
}, {
"active": true,
"note": 7,
"slide": false,
"accent": true
}, {
"active": false,
"note": 0,
"slide": false,
"accent": false
}]
}
};
const presetName = "white dwarf";
const key = `pulsar_lyra_preset_${presetName}`;
if (!localStorage.getItem(key)) {
if (myPreset.patches && Array.isArray(myPreset.patches)) {
myPreset.patches.forEach(patch => {
patch.id = crypto.randomUUID();
});
}
localStorage.setItem(key, JSON.stringify(myPreset));
}
}
setupControls() {
const audio = this.audioSystem;
const addListener = (element, event, handler, options = {}) => {
element.addEventListener(event, handler, options);
this.eventHandlers.push({
element,
event,
handler,
options
});
};
addListener(document.getElementById("playButton"), "click", () => audio.togglePlay());
addListener(document.getElementById("randomizeButton"), "click", () => audio.randomizeSequences());
addListener(document.getElementById("bpm"), "input", e => {
const v = parseFloat(e.target.value);
audio.setBPM(v);
document.getElementById("bpmValue").textContent = String(v);
});
// =======================================================================
// Swing Control
// =======================================================================
const swingSlider = document.createElement('input');
swingSlider.type = 'range';
swingSlider.id = 'swing';
swingSlider.min = 0;
swingSlider.max = 0.75;
swingSlider.step = 0.01;
swingSlider.value = 0;
const swingLabel = document.createElement('label');
swingLabel.htmlFor = 'swing';
swingLabel.textContent = 'SWING:';
const swingValue = document.createElement('span');
swingValue.id = 'swingValue';
swingValue.textContent = '0%';
swingValue.style.minWidth = '35px';
const swingRow = document.createElement('div');
swingRow.className = 'row';
swingRow.appendChild(swingLabel);
swingRow.appendChild(swingSlider);
swingRow.appendChild(swingValue);
const bpmRow = document.getElementById('bpm').parentElement;
bpmRow.parentElement.insertBefore(swingRow, bpmRow.nextSibling);
addListener(swingSlider, 'input', e => {
audio.swing = parseFloat(e.target.value);
swingValue.textContent = `${Math.round(audio.swing * 100)}%`;
});
// =======================================================================
['kickPitch', 'kickDecay', 'kickDrive', 'bassOctave', 'bassDetune', 'bassFilterCutoff', 'bassFilterQ', 'snareTone', 'snareSnappy', 'snareDecay', 'hatDecay', 'hatMetal'].forEach(p => addListener(document.getElementById(p), 'input', e => audio.setParam('pulsar', p, e.target.value)));
addListener(document.getElementById('masterDist'), 'input',
e => audio.setDistortionCurve(parseFloat(e.target.value)));
addListener(document.getElementById('masterReverb'), 'input', e => audio.reverbSend.gain.setTargetAtTime(parseFloat(e.target.value), audio.audioCtx.currentTime, .01));
// --- NEW: Hook up the mixer volume sliders ---
const setVolume = (gainNode, value) => {
// Schedules a smooth transition to the new volume.
// This prevents clicks and audio thread interruptions.
gainNode.gain.setTargetAtTime(value, audio.audioCtx.currentTime, 0.015);
};
addListener(document.getElementById("kickVolume"), "input", e => setVolume(audio.kickGain, parseFloat(e.target.value)));
addListener(document.getElementById("snareVolume"), "input", e => setVolume(audio.snareGain, parseFloat(e.target.value)));
addListener(document.getElementById("hatVolume"), "input", e => setVolume(audio.hatGain, parseFloat(e.target.value)));
addListener(document.getElementById("bassVolume"), "input", e => setVolume(audio.bassGain, parseFloat(e.target.value)));
addListener(document.getElementById("synthVolume"), "input", e => setVolume(audio.synthGain, parseFloat(e.target.value)));
// Also update the master volume slider for consistency and best practice.
addListener(document.getElementById('masterVolume'), 'input', e => setVolume(audio.masterGain, parseFloat(e.target.value)));
const safetyBtn = document.getElementById('safetyToggle');
addListener(safetyBtn, 'click', () => {
audio.isSafetyOn = !audio.isSafetyOn;
safetyBtn.classList.toggle('active', audio.isSafetyOn);
safetyBtn.textContent = audio.isSafetyOn ? 'SAFETY: ON' : 'SAFETY: OFF';
});
addListener(document.getElementById('savePresetButton'), 'click', () => audio.savePreset(document.getElementById('presetNameInput').value));
addListener(document.getElementById('loadPresetButton'), 'click', () => audio.loadPreset(document.getElementById('presetSelect').value));
addListener(document.getElementById('deletePresetButton'), 'click', () => {
const select = document.getElementById('presetSelect');
const nameToDelete = select.value;
if (nameToDelete && audio.deletePreset(nameToDelete)) {
if (select.options.length > 0) {
select.selectedIndex = 0;
} else {
document.getElementById('presetNameInput').value = '';
}
}
});
const exportModal = document.getElementById('exportModal');
const importModal = document.getElementById('importModal');
addListener(document.getElementById('exportStateButton'), 'click', () => {
const state = audio.getState();
const cleanState = {
...state,
patches: state.patches.map(({
source,
dest,
amount,
modRangeMin,
modRangeMax
}) => ({
source,
dest,
amount,
modRangeMin,
modRangeMax
}))
};
const stateString = `const myPreset = ${JSON.stringify(cleanState, null, 2)};`;
document.getElementById('exportTextarea').value = stateString;
exportModal.style.display = 'flex';
});
addListener(document.getElementById('exportAllButton'), 'click', () => audio.exportAllPresets());
addListener(document.getElementById('downloadPresetButton'), 'click', () => {
const text = document.getElementById('exportTextarea').value;
const blob = new Blob([text], {
type: 'text/plain'
});
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `pulsar-lyra-preset-${document.getElementById('presetSelect').value}.txt`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
});
addListener(document.getElementById('closeExportModalButton'), 'click', () => exportModal.style.display = 'none');
addListener(document.getElementById('importStateButton'), 'click', () => {
importModal.style.display = 'flex';
});
addListener(document.getElementById('applyImportButton'), 'click', () => {
try {
const text = document.getElementById('importTextarea').value;
if (!text.trim()) {
alert('Text area is empty!');
return;
}
const startIndex = text.indexOf('{');
const endIndex = text.lastIndexOf('}');
if (startIndex === -1 || endIndex === -1) {
throw new Error("Could not find a valid JSON object.");
}
const jsonString = text.substring(startIndex, endIndex + 1);
const data = JSON.parse(jsonString);
if (data.controls && data.sequences) { // Single preset
if (data.patches && Array.isArray(data.patches)) {
data.patches.forEach(p => p.id = crypto.randomUUID());
}
audio.setState(data);
alert('Single preset loaded successfully!');
} else { // Bulk presets
let count = 0;
for (const [name, presetData] of Object.entries(data)) {
if (presetData.controls && presetData.sequences) {
if (presetData.patches && Array.isArray(presetData.patches)) {
presetData.patches.forEach(p => p.id = crypto.randomUUID());
}
localStorage.setItem(`pulsar_lyra_preset_${name}`, JSON.stringify(presetData));
count++;
}
}
audio.populatePresets();
alert(`${count} presets imported successfully!`);
}
importModal.style.display = 'none';
} catch (e) {
console.error("Preset import error:", e);
alert(`Failed to load preset(s). Please check the format.\nError: ${e.message}`);
}
});
addListener(document.getElementById('closeImportModalButton'), 'click', () => importModal.style.display = 'none');
const lyraHeader = document.querySelector('#lyraContainer > h3');
addListener(lyraHeader, 'click', () => document.getElementById('lyraContainer').classList.toggle('open'));
const lyraControls = {
lyraOsc1Type: 'osc1Type',
lyraOsc1Oct: 'osc1Oct',
lyraOsc2Type: 'osc2Type',
lyraOsc2Oct: 'osc2Oct',
lyraDetune: 'detune',
lyraFMAmt: 'fmAmount',
lyraWaveFold: 'waveFold',
lyraDriveMode: 'driveMode',
lyraDrive: 'drive',
lyraFilterType: 'filterType',
lyraFilterCutoff: 'filterCutoff',
lyraFilterQ: 'filterQ',
lyraFilterEnv: 'filterEnv',
lyraLfoDepth: 'lfoDepth',
lyraAttack: 'attack',
lyraDecay: 'decay',
lyraSustain: 'sustain',
lyraRelease: 'release'
};
Object.entries(lyraControls).forEach(([id, paramName]) => {
const el = document.getElementById(id);
addListener(el, el.tagName === 'SELECT' ? 'change' : 'input', e => {
const value = el.type && el.type.includes('range') ? parseFloat(e.target.value) : e.target.value;
audio.updateLyraParam(paramName, value);
});
});
const lfoRateSlider = document.getElementById('lyraLfoRate');
const lfoSyncSelect = document.getElementById('lyraLfoSyncRate');
const lfoSyncToggle = document.getElementById('lyraLfoSyncToggle');
addListener(lfoRateSlider, 'input', e => audio.updateLyraParam('lfoRate', parseFloat(e.target.value)));
addListener(lfoSyncSelect, 'change', e => audio.updateLyraParam('lfoSyncRate', parseFloat(e.target.value)));
addListener(lfoSyncToggle, 'click', () => {
const isSynced = !lfoSyncToggle.classList.contains('active');
lfoSyncToggle.classList.toggle('active', isSynced);
audio.updateLyraParam('lfoIsSynced', isSynced);
lfoRateSlider.style.display = isSynced ? 'none' : 'block';
lfoSyncSelect.style.display = isSynced ? 'block' : 'none';
// send the current control's value explicitly
const k = isSynced ? 'lfoSyncRate' : 'lfoRate';
const v = isSynced ? parseFloat(lfoSyncSelect.value) : parseFloat(lfoRateSlider.value);
audio.updateLyraParam(k, v);
});
const fx = audio.lyraFxNodes;
const now = () => audio.audioCtx.currentTime;
addListener(document.getElementById('lyraFxType'), 'change', e => {
document.querySelectorAll('.fx-panel').forEach(p => p.style.display = 'none');
const panel = document.getElementById(`fxControls-${e.target.value}`);
if (panel) panel.style.display = 'block';
audio.switchLyraFx(e.target.value);
});
const setupFxSlider = (sliderId, wetUpdater, dryUpdater) => {
addListener(document.getElementById(sliderId), 'input', e => {
const mix = parseFloat(e.target.value);
if (wetUpdater) wetUpdater(mix);
if (dryUpdater) dryUpdater(1.0 - mix);
});
};
const setWet = val => fx.wet.gain.setTargetAtTime(val, now(), 0.01);
const setDry = val => fx.dry.gain.setTargetAtTime(val, now(), 0.01);
addListener(document.getElementById('lyraDelayTime'), 'input', e => fx.delay.delay.delayTime.setTargetAtTime(parseFloat(e.target.value), now(), 0.01));
addListener(document.getElementById('lyraDelayFeedback'), 'input', e => fx.delay.feedback.gain.setTargetAtTime(parseFloat(e.target.value), now(), 0.01));
setupFxSlider('lyraDelayMix', setWet, setDry);
addListener(document.getElementById('phaserRate'), 'input', e => fx.phaser.lfo.frequency.setTargetAtTime(parseFloat(e.target.value), now(), 0.01));
addListener(document.getElementById('phaserDepth'), 'input', e => fx.phaser.lfoGain.gain.setTargetAtTime(parseFloat(e.target.value), now(), 0.01));
addListener(document.getElementById('phaserFeedback'), 'input', e => fx.phaser.feedback.gain.setTargetAtTime(parseFloat(e.target.value), now(), 0.01));
setupFxSlider('phaserMix', setWet, setDry);
addListener(document.getElementById('flangerRate'), 'input', e => fx.flanger.lfo.frequency.setTargetAtTime(parseFloat(e.target.value), now(), 0.01));
addListener(document.getElementById('flangerDepth'), 'input', e => fx.flanger.lfoGain.gain.setTargetAtTime(parseFloat(e.target.value), now(), 0.01));
addListener(document.getElementById('flangerFeedback'), 'input', e => fx.flanger.feedback.gain.setTargetAtTime(parseFloat(e.target.value), now(), 0.01));
setupFxSlider('flangerMix', setWet, setDry);
addListener(document.getElementById('quazarRate'), 'input', e => fx.quazar.lfo.frequency.setTargetAtTime(parseFloat(e.target.value), now(), 0.01));
addListener(document.getElementById('quazarReso'), 'input', e => fx.quazar.filter.Q.setTargetAtTime(parseFloat(e.target.value), now(), 0.01));
addListener(document.getElementById('quazarFreq'), 'input', e => fx.quazar.lfoGain.gain.setTargetAtTime(parseFloat(e.target.value), now(), 0.01));
setupFxSlider('quazarMix', setWet, setDry);
addListener(document.getElementById('chopperRate'), 'change', e => audio.setBPM(audio.bpm));
addListener(document.getElementById('chopperDepth'), 'input', e => fx.chopper.lfoGain.gain.setTargetAtTime(parseFloat(e.target.value), now(), 0.01));
addListener(document.getElementById('crusherBitDepth'), 'input', e => {
if (fx.bitcrusher?.node) fx.bitcrusher.node.parameters.get('bitDepth').setTargetAtTime(parseFloat(e.target.value), now(), 0.01)
});
addListener(document.getElementById('crusherFreq'), 'input', e => {
if (fx.bitcrusher?.node) fx.bitcrusher.node.parameters.get('frequencyReduction').setTargetAtTime(parseFloat(e.target.value), now(), 0.01)
});
setupFxSlider('crusherMix', setWet, setDry);
addListener(document.getElementById('lyraRecButton'), 'click', () => audio.toggleLyraRec());
addListener(document.getElementById('lyraPlayButton'), 'click', () => audio.toggleLyraPlay());
addListener(document.getElementById('lyraWipeButton'), 'click', () => audio.wipeLyraLoop());
const keyMap = {
'KeyA': 1,
'KeyS': 1,
'KeyD': 1,
'KeyF': 1,
'KeyG': 1,
'KeyH': 1,
'KeyJ': 1,
'KeyK': 1,
'KeyL': 1
};
const onKeyDown = e => {
if (keyMap[e.code] && !e.repeat) {
if (audio.audioCtx.state === "suspended") audio.audioCtx.resume();
audio.handleLyraKey(e.code, true);
}
};
const onKeyUp = e => {
if (keyMap[e.code]) audio.handleLyraKey(e.code, false);
};
addListener(document, 'keydown', onKeyDown);
addListener(document, 'keyup', onKeyUp);
const sS = document.getElementById('patchSource'),
dS = document.getElementById('patchDest');
audio.getModSources().forEach(s => sS.innerHTML += `<option value="${s}">${s}</option>`);
audio.getModDestinations().forEach(d => dS.innerHTML += `<option value="${d}">${d}</option>`);
addListener(document.getElementById('addPatchButton'), 'click', () => {
const amtRaw = parseFloat(document.getElementById('patchAmount').value);
const amt = Math.round((isNaN(amtRaw) ? 0 : amtRaw) * 100) / 100; // clamp to 2dp as number
audio.addPatch(sS.value, dS.value, amt);
});
// =======================================================================
// Add Global Modulation Controls to the DOM
// =======================================================================
const patchBay = document.getElementById('patchBay');
const globalControlsContainer = document.createElement('div');
globalControlsContainer.className = 'control-group';
globalControlsContainer.style.marginTop = '15px';
const title = document.createElement('h4');
title.textContent = 'GLOBAL CONTROLS';
globalControlsContainer.appendChild(title);
const buttonRow = document.createElement('div');
buttonRow.className = 'row';
// --- Freeze Mods Button ---
const freezeButton = document.createElement("button");
freezeButton.id = "freezeModsButton";
freezeButton.textContent = "FREEZE MODS";
freezeButton.style.fontSize = '0.8em';
buttonRow.appendChild(freezeButton);
let modsFrozen = false;
addListener(freezeButton, "click", () => {
modsFrozen = !modsFrozen;
audio.modsFrozen = modsFrozen; // Update the audio system state
audio.patches.forEach(p => {
if (modsFrozen) {
p.isLocked = true;
p.lockedValue = audio.modSources[p.source].value;
} else {
p.isLocked = false;
}
});
freezeButton.textContent = modsFrozen ? "UNFREEZE" : "FREEZE MODS";
freezeButton.classList.toggle('active', modsFrozen);
});
// --- Mute Mods Button (Replaces Gate) ---
const muteButton = document.createElement("button");
muteButton.id = "modMuteButton";
muteButton.textContent = "MUTE MODS";
muteButton.style.fontSize = '0.8em';
buttonRow.appendChild(muteButton);
addListener(muteButton, "click", () => {
audio.isMuted = !audio.isMuted; // Toggle the main flag in the audio system
if (audio.isMuted) {
// When muting, reset everything to its raw slider value.
audio.resetAllModulatedParamsToRaw();
}
muteButton.textContent = audio.isMuted ? "UNMUTE" : "MUTE MODS";
muteButton.classList.toggle('active', audio.isMuted);
});
// --- Shuffle Patches Button ---
const shuffleButton = document.createElement("button");
shuffleButton.id = "shufflePatchesButton";
shuffleButton.textContent = "SHUFFLE";
shuffleButton.style.fontSize = '0.8em';
buttonRow.appendChild(shuffleButton);
addListener(shuffleButton, "click", () => {
const p = audio.patches;
for (let i = p.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[p[i], p[j]] = [p[j], p[i]]; // Modern swap
}
audio.renderPatchesUI();
});
globalControlsContainer.appendChild(buttonRow);
// --- Global Mod Scale Slider ---
const gmDiv = document.createElement("div");
gmDiv.className = 'row';
gmDiv.style.marginTop = "15px";
gmDiv.innerHTML = `<label for="globalModScale" style="font-size:0.8em;">GLOBAL MOD:</label><input type="range" id="globalModScale" min="0" max="2" step="0.01" value="1">`;
globalControlsContainer.appendChild(gmDiv);
const gms = gmDiv.querySelector("#globalModScale");
addListener(gms, "input", e => {
audio.globalModScale = parseFloat(e.target.value);
});
// Insert the new controls section into the patch bay
patchBay.insertBefore(globalControlsContainer, patchBay.querySelector('#activePatches'));
// =======================================================================
for (const t in audio.sequences) {
const c = document.querySelector(`.pattern-row[data-type="${t}"] .steps-container`);
if (!c) continue;
for (let i = 0; i < audio.sequenceLength; i++) {
const s = document.createElement('div');
s.className = 'step';
s.dataset.index = i;
s.classList.toggle('active', audio.sequences[t][i]);
addListener(s, 'click', (e) => {
if (e.shiftKey) {
if (!audio.sequences[t][i]) return;
const meta = audio.stepMeta[t][i] = audio.stepMeta[t][i] || {
velocity: 1,
prob: 1,
ratchet: 1
};
const currentCond = meta.triggerPass || 'always';
if (currentCond === 'always') meta.triggerPass = 'first';
else if (currentCond === 'first') meta.triggerPass = 'second';
else meta.triggerPass = 'always';
audio.updatePatternEditor();
return;
}
if (e.ctrlKey || e.metaKey) {
if (t === 'bass' && audio.sequences.bass[i]) {
const meta = audio.stepMeta.bass[i] = audio.stepMeta.bass[i] || {};
meta.accent = !meta.accent;
audio.updatePatternEditor();
}
return;
}
audio.sequences[t][i] = !audio.sequences[t][i];
audio.updatePatternEditor();
});
addListener(s, 'contextmenu', e => {
e.preventDefault();
e.stopPropagation();
if (!audio.sequences[t][i]) return;
const meta = (audio.stepMeta[t] && audio.stepMeta[t][i]) ? audio.stepMeta[t][i] : {
velocity: 1,
prob: 1,
ratchet: 1,
accent: false
};
openCellEditor(audio, t, i, meta);
});
c.appendChild(s);
}
}
document.querySelectorAll('.pattern-label').forEach(label => {
const type = label.closest('.pattern-row').dataset.type;
addListener(label, 'click', e => {
e.preventDefault();
openRowContextMenu(audio, type, e);
});
});
const kaosPad = document.getElementById('kaosPad');
let isKaosDragging = false;
const handleKaosMove = e => {
if (!isKaosDragging) return;
e.preventDefault();
const r = kaosPad.getBoundingClientRect(),
cX = e.touches ? e.touches[0].clientX : e.clientX,
cY = e.touches ? e.touches[0].clientY : e.clientY;
let x = (cX - r.left) / r.width,
y = (cY - r.top) / r.height;
x = Math.max(0, Math.min(1, x));
y = Math.max(0, Math.min(1, y));
kaosPad.firstElementChild.style.left = `${x*100}%`;
kaosPad.firstElementChild.style.top = `${y*100}%`;
audio.modSources['KAOS X'].value = x;
audio.modSources['KAOS Y'].value = 1.0 - y;
};
const startDrag = e => {
isKaosDragging = true;
handleKaosMove(e);
};
const stopDrag = () => {
isKaosDragging = false;
};
addListener(kaosPad, 'mousedown', startDrag);
addListener(document, 'mousemove', handleKaosMove);
addListener(document, 'mouseup', stopDrag);
addListener(kaosPad, 'touchstart', startDrag, {
passive: false
});
addListener(document, 'touchmove', handleKaosMove, {
passive: false
});
addListener(document, 'touchend', stopDrag);
addListener(document, 'touchcancel', stopDrag);
addListener(document, "keydown", e => {
if (e.code === "Space" && !e.target.closest('input,button,select,textarea')) {
e.preventDefault();
audio.togglePlay();
}
});
addListener(document, 'visibilitychange', () => {
if (document.visibilityState === 'visible' && audio.audioCtx.state === 'suspended') {
audio.audioCtx.resume();
}
});
addListener(document.getElementById('lyraSeqToggle'), 'click', () => audio.toggleLyraSequencer());
addListener(document.getElementById('lyraSeqRandomize'), 'click', () => audio.randomizeLyraSequence());
addListener(document.getElementById('lyraPortamento'), 'input', e => {
audio.lyraParams.portamento = parseFloat(e.target.value) / 1000;
});
addListener(document.getElementById('randomizeLyraSettingsButton'), 'click', () => {
this.audioSystem.randomizeLyraSettings();
});
addListener(document.getElementById('lyraStepGrid'), 'click', e => {
const stepEl = e.target.closest('.lyra-step');
if (!stepEl) return;
const stepIndex = parseInt(stepEl.dataset.step, 10);
if (isNaN(stepIndex)) return;
const step = audio.lyraSequencer.steps[stepIndex];
if (e.shiftKey) {
step.slide = !step.slide;
} else if (e.ctrlKey || e.metaKey) {
step.accent = !step.accent;
} else {
step.active = !step.active;
}
audio.updateLyraSequencerUI();
});
addListener(document.getElementById('lyraStepGrid'), 'contextmenu', e => {
e.preventDefault(); // Prevent the default browser right-click menu
const stepEl = e.target.closest('.lyra-step');
if (!stepEl) return;
const stepIndex = parseInt(stepEl.dataset.step, 10);
if (isNaN(stepIndex)) return;
openSynthStepEditor(audio, stepIndex);
});
}
dispose() {
this.eventHandlers.forEach(({
element,
event,
handler,
options
}) => {
element.removeEventListener(event, handler, options);
});
this.eventHandlers = [];
}
}
window.app = new App();
// localStorage .clear();
</script>
</body>
</html>
// pastable import preset
const myPreset = {
controls: [
{
id: "masterVolume",
value: "0.4"
},
{
id: "bpm",
value: "114"
},
{
id: "presetSelect",
value: "white dwarf"
},
{
id: "presetNameInput",
value: "icy particles"
},
{
id: "lyraPortamento",
value: "166"
},
{
id: "lyraOsc1Type",
value: "square"
},
{
id: "lyraOsc1Oct",
value: "-1"
},
{
id: "lyraOsc2Type",
value: "square"
},
{
id: "lyraOsc2Oct",
value: "-1"
},
{
id: "lyraDetune",
value: "0"
},
{
id: "lyraFMAmt",
value: "0"
},
{
id: "lyraWaveFold",
value: "0"
},
{
id: "lyraDrive",
value: "0"
},
{
id: "lyraFilterType",
value: "bandpass"
},
{
id: "lyraFilterCutoff",
value: "8564"
},
{
id: "lyraFilterQ",
value: "0"
},
{
id: "lyraFilterEnv",
value: "-3802"
},
{
id: "lyraLfoRate",
value: "4.1"
},
{
id: "lyraLfoSyncRate",
value: "0.25"
},
{
id: "lyraLfoSyncToggle",
value: true
},
{
id: "lyraLfoDepth",
value: "431"
},
{
id: "lyraAttack",
value: "0.01"
},
{
id: "lyraDecay",
value: "0.01"
},
{
id: "lyraSustain",
value: "0.02"
},
{
id: "lyraRelease",
value: "1.69"
},
{
id: "lyraFxType",
value: "Chopper"
},
{
id: "lyraDelayTime",
value: "0.22"
},
{
id: "lyraDelayFeedback",
value: "0"
},
{
id: "lyraDelayMix",
value: "0.85"
},
{
id: "phaserRate",
value: "4.2"
},
{
id: "phaserDepth",
value: "1200"
},
{
id: "phaserFeedback",
value: "0.56"
},
{
id: "phaserMix",
value: "0.5"
},
{
id: "flangerRate",
value: "0.2"
},
{
id: "flangerDepth",
value: "0.005"
},
{
id: "flangerFeedback",
value: "0.5"
},
{
id: "flangerMix",
value: "0.5"
},
{
id: "quazarRate",
value: "0.4"
},
{
id: "quazarReso",
value: "15.5"
},
{
id: "quazarFreq",
value: "4440"
},
{
id: "quazarMix",
value: "0.46"
},
{
id: "chopperRate",
value: "0.5"
},
{
id: "chopperDepth",
value: "0.27"
},
{
id: "crusherBitDepth",
value: "1"
},
{
id: "crusherFreq",
value: "1"
},
{
id: "crusherMix",
value: "0.81"
},
{
id: "patchSource",
value: "S&H (2 steps)"
},
{
id: "patchDest",
value: "SNARE DECAY"
},
{
id: "patchAmount",
value: "1.9"
},
{
id: "kickPitch",
value: "41"
},
{
id: "kickDecay",
value: "0.23"
},
{
id: "kickDrive",
value: "0.48"
},
{
id: "bassOctave",
value: "0"
},
{
id: "bassDetune",
value: "1"
},
{
id: "bassFilterCutoff",
value: "353"
},
{
id: "bassFilterQ",
value: "14"
},
{
id: "snareTone",
value: "122"
},
{
id: "snareSnappy",
value: "1620"
},
{
id: "snareDecay",
value: "0.06"
},
{
id: "hatDecay",
value: "0.1"
},
{
id: "hatMetal",
value: "5879"
},
{
id: "masterDist",
value: "0"
},
{
id: "masterReverb",
value: "0.08"
}
],
patches: [
{
source: "S&H (8 steps)",
dest: "MASTER REVERB",
amount: "1.90",
modRangeMin: -1,
modRangeMax: 1
},
{
source: "S&H (32 steps)",
dest: "HHT DECAY",
amount: 1.9,
modRangeMin: -1,
modRangeMax: 0.57
},
{
source: "S&H (32 steps)",
dest: "LYRA OSC2 OCT",
amount: "1.90",
modRangeMin: -1,
modRangeMax: 1
},
{
source: "S&H (4 steps)",
dest: "LYRA OSC1 OCT",
amount: "1.90",
modRangeMin: -1,
modRangeMax: 1
},
{
source: "S&H (64 steps)",
dest: "LYRA SLIDE",
amount: "1.90",
modRangeMin: -1,
modRangeMax: 1
},
{
source: "S&H (64 steps)",
dest: "LYRA DECAY",
amount: 1.25,
modRangeMin: -0.32,
modRangeMax: 0.19
},
{
source: "S&H (2 steps)",
dest: "SNARE DECAY",
amount: "1.90",
modRangeMin: -1,
modRangeMax: 1
}
],
sequences: {
kick: [
true,
false,
false,
false,
false,
false,
true,
false,
false,
false,
false,
true,
false,
false,
false,
false,
true,
false,
false,
false,
false,
true,
false,
false,
false,
false,
true,
false,
false,
false,
false,
false
],
snare: [
false,
false,
false,
false,
true,
false,
false,
false,
false,
false,
false,
false,
true,
false,
false,
false,
false,
false,
false,
false,
true,
false,
false,
false,
false,
false,
false,
false,
true,
false,
false,
false
],
hat: [
false,
false,
true,
false,
false,
false,
true,
false,
false,
false,
true,
false,
false,
false,
true,
false,
false,
false,
true,
false,
false,
false,
true,
false,
false,
false,
true,
false,
false,
false,
true,
false
],
bass: [
true,
false,
false,
true,
false,
false,
false,
true,
false,
true,
true,
true,
false,
false,
true,
true,
false,
false,
false,
false,
false,
false,
true,
false,
false,
false,
false,
true,
true,
false,
false,
false
],
lyraGate: [
true,
false,
true,
false,
true,
true,
true,
true,
true,
true,
true,
false,
true,
true,
true,
true,
true,
true,
true,
true,
true,
true,
true,
true,
false,
true,
true,
false,
false,
true,
false,
true
]
},
lyraSequencer: {
isPlaying: true,
steps: [
{
active: true,
note: 14,
slide: false,
accent: true
},
{
active: true,
note: 3,
slide: true,
accent: false
},
{
active: true,
note: 14,
slide: false,
accent: false
},
{
active: true,
note: 7,
slide: false,
accent: false
},
{
active: false,
note: 10,
slide: false,
accent: false
},
{
active: true,
note: 0,
slide: false,
accent: false
},
{
active: false,
note: 2,
slide: false,
accent: false
},
{
active: true,
note: 12,
slide: true,
accent: true
},
{
active: true,
note: 7,
slide: false,
accent: false
},
{
active: true,
note: 2,
slide: false,
accent: false
},
{
active: false,
note: 3,
slide: false,
accent: false
},
{
active: true,
note: 8,
slide: true,
accent: true
},
{
active: true,
note: 10,
slide: false,
accent: false
},
{
active: true,
note: 10,
slide: true,
accent: true
},
{
active: false,
note: 3,
slide: false,
accent: false
},
{
active: true,
note: 0,
slide: true,
accent: false
}
]
},
stepMeta: {
kick: {},
snare: {},
hat: {},
bass: {},
lyraGate: {}
},
version: 2
};
// pastable import preset
const myPreset = {
controls: [
{
id: "masterVolume",
value: "0.4"
},
{
id: "bpm",
value: "125"
},
{
id: "swing",
value: "0.1"
},
{
id: "presetSelect",
value: "quantum mass"
},
{
id: "presetNameInput",
value: "quantum mass"
},
{
id: "lyraPortamento",
value: "0"
},
{
id: "lyraOsc1Type",
value: "sawtooth"
},
{
id: "lyraOsc1Oct",
value: "2"
},
{
id: "lyraOsc2Type",
value: "square"
},
{
id: "lyraOsc2Oct",
value: "1"
},
{
id: "lyraDetune",
value: "13"
},
{
id: "lyraFMAmt",
value: "80"
},
{
id: "lyraWaveFold",
value: "0.07"
},
{
id: "lyraDriveMode",
value: "soft clip"
},
{
id: "lyraDrive",
value: "0.38"
},
{
id: "lyraFilterType",
value: "notch"
},
{
id: "lyraFilterCutoff",
value: "4981"
},
{
id: "lyraFilterQ",
value: "0.1"
},
{
id: "lyraFilterEnv",
value: "-942"
},
{
id: "lyraLfoRate",
value: "0.3"
},
{
id: "lyraLfoSyncRate",
value: "0.25"
},
{
id: "lyraLfoSyncToggle",
value: true
},
{
id: "lyraLfoDepth",
value: "1499"
},
{
id: "lyraAttack",
value: "0.02"
},
{
id: "lyraDecay",
value: "0.08"
},
{
id: "lyraSustain",
value: "0.04"
},
{
id: "lyraRelease",
value: "0.07"
},
{
id: "lyraFxType",
value: "Delay"
},
{
id: "lyraDelayTime",
value: "0.14"
},
{
id: "lyraDelayFeedback",
value: "0.37"
},
{
id: "lyraDelayMix",
value: "0.2"
},
{
id: "phaserRate",
value: "0.5"
},
{
id: "phaserDepth",
value: "1200"
},
{
id: "phaserFeedback",
value: "0.3"
},
{
id: "phaserMix",
value: "0.5"
},
{
id: "flangerRate",
value: "0.2"
},
{
id: "flangerDepth",
value: "0.005"
},
{
id: "flangerFeedback",
value: "0.5"
},
{
id: "flangerMix",
value: "0.5"
},
{
id: "quazarRate",
value: "2"
},
{
id: "quazarReso",
value: "25"
},
{
id: "quazarFreq",
value: "1500"
},
{
id: "quazarMix",
value: "1"
},
{
id: "chopperRate",
value: "2"
},
{
id: "chopperDepth",
value: "1"
},
{
id: "crusherBitDepth",
value: "8"
},
{
id: "crusherFreq",
value: "0.5"
},
{
id: "crusherMix",
value: "0.5"
},
{
id: "patchSource",
value: "S&H (8 steps)"
},
{
id: "patchDest",
value: "LYRA OSC2 OCT"
},
{
id: "patchAmount",
value: "0.5"
},
{
id: "globalModScale",
value: "1"
},
{
id: "kickPitch",
value: "40"
},
{
id: "kickDecay",
value: "0.35"
},
{
id: "kickDrive",
value: "0.5"
},
{
id: "bassOctave",
value: "-2"
},
{
id: "bassDetune",
value: "5"
},
{
id: "bassFilterCutoff",
value: "400"
},
{
id: "bassFilterQ",
value: "10"
},
{
id: "snareTone",
value: "180"
},
{
id: "snareSnappy",
value: "3000"
},
{
id: "snareDecay",
value: "0.2"
},
{
id: "hatDecay",
value: "0.3"
},
{
id: "hatMetal",
value: "6674"
},
{
id: "masterDist",
value: "0.08"
},
{
id: "masterReverb",
value: "0.5"
}
],
patches: [
{
source: "S&H (Random)",
dest: "BASS FILTER",
amount: 0.8,
modRangeMin: -1,
modRangeMax: 1
},
{
source: "LFO 1 (Slow)",
dest: "HHT DECAY",
amount: 1,
modRangeMin: -1,
modRangeMax: 1
},
{
source: "KAOS X",
dest: "LYRA FM AMT",
amount: 1.5,
modRangeMin: -1,
modRangeMax: 1
},
{
source: "KAOS Y",
dest: "LYRA DELAY FEEDBACK",
amount: 0.9,
modRangeMin: -1,
modRangeMax: 1
},
{
source: "S&H (8 steps)",
dest: "LYRA OSC2 OCT",
amount: 0.5,
modRangeMin: -0.5,
modRangeMax: 1
}
],
isSafetyOn: false,
sequences: {
kick: [
true,
false,
true,
false,
false,
false,
false,
false,
false,
false,
true,
false,
false,
false,
false,
false,
true,
false,
false,
false,
false,
false,
false,
false,
true,
false,
true,
false,
false,
false,
false,
true
],
snare: [
false,
false,
false,
false,
true,
false,
false,
false,
false,
false,
false,
false,
true,
false,
false,
false,
false,
false,
false,
false,
true,
false,
false,
false,
false,
false,
false,
false,
true,
false,
false,
false
],
hat: [
false,
false,
true,
true,
false,
false,
true,
true,
false,
false,
true,
false,
false,
true,
true,
false,
false,
false,
true,
true,
false,
false,
true,
true,
false,
false,
true,
true,
false,
false,
true,
false
],
bass: [
true,
false,
false,
false,
false,
false,
true,
false,
false,
true,
false,
false,
false,
false,
false,
false,
true,
false,
false,
false,
false,
false,
true,
false,
false,
true,
false,
false,
false,
false,
false,
false
],
lyraGate: [
true,
true,
true,
false,
true,
false,
false,
false,
true,
false,
false,
false,
true,
false,
true,
false,
true,
false,
true,
true,
true,
false,
false,
false,
true,
false,
true,
true,
false,
true,
false,
true
]
},
lyraSequencer: {
isPlaying: true,
steps: [
{
active: true,
note: 0,
slide: false,
accent: true
},
{
active: false,
note: 0,
slide: false,
accent: false
},
{
active: true,
note: 0,
slide: false,
accent: false
},
{
active: true,
note: 7,
slide: true,
accent: false
},
{
active: true,
note: 3,
slide: false,
accent: true
},
{
active: false,
note: 0,
slide: false,
accent: false
},
{
active: true,
note: 3,
slide: false,
accent: false
},
{
active: true,
note: 0,
slide: false,
accent: false
},
{
active: true,
note: 0,
slide: false,
accent: true
},
{
active: false,
note: 0,
slide: false,
accent: false
},
{
active: true,
note: 0,
slide: false,
accent: false
},
{
active: true,
note: 7,
slide: false,
accent: false
},
{
active: true,
note: 8,
slide: true,
accent: true
},
{
active: false,
note: 0,
slide: false,
accent: false
},
{
active: true,
note: 7,
slide: false,
accent: false
},
{
active: true,
note: 0,
slide: false,
accent: false
}
]
},
stepMeta: {
kick: {
2: {
velocity: 0.35,
prob: 0.12,
ratchet: 1
}
},
snare: {},
hat: {
2: {
velocity: 1,
prob: 0.06451612903225806,
ratchet: 1
},
3: {
velocity: 1,
prob: 0.0967741935483871,
ratchet: 1
},
6: {
velocity: 1,
prob: 0.1935483870967742,
ratchet: 1
},
7: {
velocity: 1,
prob: 0.22580645161290322,
ratchet: 1
},
10: {
velocity: 1,
prob: 0.3225806451612903,
ratchet: 1
},
13: {
velocity: 1,
prob: 0.41935483870967744,
ratchet: 1
},
14: {
velocity: 1,
prob: 0.45161290322580644,
ratchet: 1
},
18: {
velocity: 1,
prob: 0.5806451612903226,
ratchet: 1
},
19: {
velocity: 1,
prob: 0.6129032258064516,
ratchet: 1
},
22: {
velocity: 1,
prob: 0.7096774193548387,
ratchet: 1
},
23: {
velocity: 1,
prob: 0.7419354838709677,
ratchet: 1
},
26: {
velocity: 1,
prob: 0.8387096774193549,
ratchet: 1
},
27: {
velocity: 1,
prob: 0.8709677419354839,
ratchet: 1
},
30: {
velocity: 1,
prob: 0.967741935483871,
ratchet: 1
}
},
bass: {},
lyraGate: {}
},
version: 2
};
const myPreset = {
controls: [
{
id: "masterVolume",
value: "0.4"
},
{
id: "bpm",
value: "84"
},
{
id: "swing",
value: "0"
},
{
id: "presetSelect",
value: "waning crescent"
},
{
id: "presetNameInput",
value: "waning crescent"
},
{
id: "lyraPortamento",
value: "0"
},
{
id: "lyraOsc1Type",
value: "sine"
},
{
id: "lyraOsc1Oct",
value: "-2"
},
{
id: "lyraOsc2Type",
value: "sine"
},
{
id: "lyraOsc2Oct",
value: "0"
},
{
id: "lyraDetune",
value: "21"
},
{
id: "lyraFMAmt",
value: "43"
},
{
id: "lyraWaveFold",
value: "0"
},
{
id: "lyraDriveMode",
value: "wavefold"
},
{
id: "lyraDrive",
value: "0"
},
{
id: "lyraFilterType",
value: "lowpass"
},
{
id: "lyraFilterCutoff",
value: "19620"
},
{
id: "lyraFilterQ",
value: "1.8"
},
{
id: "lyraFilterEnv",
value: "2580"
},
{
id: "lyraLfoRate",
value: "1.2"
},
{
id: "lyraLfoSyncRate",
value: "0.25"
},
{
id: "lyraLfoSyncToggle",
value: true
},
{
id: "lyraLfoDepth",
value: "2635"
},
{
id: "lyraAttack",
value: "1.05"
},
{
id: "lyraDecay",
value: "0.26"
},
{
id: "lyraSustain",
value: "0.17"
},
{
id: "lyraRelease",
value: "1.58"
},
{
id: "lyraFxType",
value: "Chopper"
},
{
id: "lyraDelayTime",
value: "0.32"
},
{
id: "lyraDelayFeedback",
value: "0.13"
},
{
id: "lyraDelayMix",
value: "0.18"
},
{
id: "phaserRate",
value: "4.2"
},
{
id: "phaserDepth",
value: "1200"
},
{
id: "phaserFeedback",
value: "0.56"
},
{
id: "phaserMix",
value: "0.5"
},
{
id: "flangerRate",
value: "0.2"
},
{
id: "flangerDepth",
value: "0.005"
},
{
id: "flangerFeedback",
value: "0.5"
},
{
id: "flangerMix",
value: "0.5"
},
{
id: "quazarRate",
value: "0.4"
},
{
id: "quazarReso",
value: "15.5"
},
{
id: "quazarFreq",
value: "4440"
},
{
id: "quazarMix",
value: "0.46"
},
{
id: "chopperRate",
value: "0.5"
},
{
id: "chopperDepth",
value: "0.06"
},
{
id: "crusherBitDepth",
value: "8"
},
{
id: "crusherFreq",
value: "0.5"
},
{
id: "crusherMix",
value: "0.5"
},
{
id: "patchSource",
value: "LFO 1 (Slower)"
},
{
id: "patchDest",
value: "LYRA OSC2 TYPE"
},
{
id: "patchAmount",
value: "-2"
},
{
id: "globalModScale",
value: "1.03"
},
{
id: "kickPitch",
value: "47"
},
{
id: "kickDecay",
value: "0.32"
},
{
id: "kickDrive",
value: "0.1"
},
{
id: "bassOctave",
value: "-2"
},
{
id: "bassDetune",
value: "0"
},
{
id: "bassFilterCutoff",
value: "100"
},
{
id: "bassFilterQ",
value: "0"
},
{
id: "snareTone",
value: "167"
},
{
id: "snareSnappy",
value: "1259"
},
{
id: "snareDecay",
value: "0.12"
},
{
id: "hatDecay",
value: "0.18"
},
{
id: "hatMetal",
value: "6930"
},
{
id: "masterDist",
value: "0.02"
},
{
id: "masterReverb",
value: "0.26"
}
],
patches: [
{
source: "CLK/32",
dest: "LYRA DRIVE",
amount: 0.05,
modRangeMin: -1,
modRangeMax: 0.5
},
{
source: "S&H (8 steps)",
dest: "LYRA OSC1 OCT",
amount: 0.5,
modRangeMin: -1,
modRangeMax: 1
},
{
source: "S&H (2 steps)",
dest: "MASTER REVERB",
amount: "2.00",
modRangeMin: -1,
modRangeMax: 1
},
{
source: "LFO 1 (Slower)",
dest: "LYRA OSC2 TYPE",
amount: "-2.00",
modRangeMin: -1,
modRangeMax: 1
}
],
isSafetyOn: false,
sequences: {
kick: [
true,
false,
true,
false,
false,
false,
false,
false,
false,
false,
true,
false,
false,
false,
false,
false,
true,
false,
true,
false,
false,
false,
false,
false,
false,
false,
true,
false,
false,
false,
false,
false
],
snare: [
false,
false,
false,
false,
true,
false,
false,
false,
false,
false,
false,
false,
true,
false,
false,
false,
false,
false,
false,
false,
true,
false,
false,
false,
false,
false,
false,
false,
true,
false,
false,
false
],
hat: [
false,
false,
true,
false,
false,
false,
true,
false,
false,
false,
true,
false,
false,
false,
true,
false,
false,
false,
true,
false,
false,
false,
true,
false,
false,
false,
true,
false,
false,
false,
true,
false
],
bass: [
false,
false,
false,
true,
false,
false,
false,
true,
false,
false,
false,
true,
false,
false,
false,
true,
false,
false,
false,
true,
false,
false,
false,
true,
false,
false,
false,
true,
false,
false,
false,
true
],
lyraGate: [
false,
true,
false,
true,
false,
false,
true,
false,
false,
false,
true,
true,
false,
true,
false,
true,
true,
false,
true,
false,
true,
true,
false,
true,
true,
true,
false,
true,
false,
true,
true,
true
]
},
lyraSequencer: {
isPlaying: true,
steps: [
{
active: false,
note: 0,
slide: false,
accent: true,
gate: 1
},
{
active: true,
note: 3,
slide: false,
accent: false,
gate: 1
},
{
active: false,
note: 2,
slide: true,
accent: false,
gate: 1
},
{
active: true,
note: 0,
slide: false,
accent: false,
gate: 1
},
{
active: true,
note: 14,
slide: true,
accent: false,
gate: 1
},
{
active: false,
note: 2,
slide: false,
accent: false,
gate: 1
},
{
active: true,
note: 5,
slide: true,
accent: false,
gate: 1
},
{
active: true,
note: 14,
slide: false,
accent: true,
gate: 1
},
{
active: false,
note: 14,
slide: true,
accent: false,
gate: 1
},
{
active: true,
note: 0,
slide: false,
accent: false,
gate: 1
},
{
active: false,
note: 15,
slide: false,
accent: false,
gate: 1
},
{
active: true,
note: 7,
slide: false,
accent: false,
gate: 1
},
{
active: true,
note: 15,
slide: false,
accent: false,
gate: 1
},
{
active: false,
note: 3,
slide: false,
accent: false,
gate: 1
},
{
active: true,
note: 14,
slide: false,
accent: true,
gate: 1
},
{
active: true,
note: 12,
slide: false,
accent: true,
gate: 1
}
]
},
stepMeta: {
kick: {},
snare: {},
hat: {},
bass: {},
lyraGate: {}
},
version: 2
};
const myPreset = {
controls: [
{
id: "masterVolume",
value: "0.4"
},
{
id: "bpm",
value: "111"
},
{
id: "swing",
value: "0"
},
{
id: "kickVolume",
value: "1"
},
{
id: "snareVolume",
value: "0.8"
},
{
id: "hatVolume",
value: "0.8"
},
{
id: "bassVolume",
value: "0.8"
},
{
id: "synthVolume",
value: "0.7"
},
{
id: "presetSelect",
value: "solar flare"
},
{
id: "presetNameInput",
value: "lunar standstill"
},
{
id: "lyraPortamento",
value: "0"
},
{
id: "lyraOsc1Type",
value: "triangle"
},
{
id: "lyraOsc1Oct",
value: "1"
},
{
id: "lyraOsc2Type",
value: "sine"
},
{
id: "lyraOsc2Oct",
value: "0"
},
{
id: "lyraDetune",
value: "9"
},
{
id: "lyraFMAmt",
value: "65"
},
{
id: "lyraWaveFold",
value: "0.14"
},
{
id: "lyraDriveMode",
value: "soft clip"
},
{
id: "lyraDrive",
value: "0.06"
},
{
id: "lyraFilterType",
value: "highpass"
},
{
id: "lyraFilterCutoff",
value: "12527"
},
{
id: "lyraFilterQ",
value: "1.1"
},
{
id: "lyraFilterEnv",
value: "-4288"
},
{
id: "lyraLfoRate",
value: "0.3"
},
{
id: "lyraLfoSyncRate",
value: "2"
},
{
id: "lyraLfoSyncToggle",
value: true
},
{
id: "lyraLfoDepth",
value: "881"
},
{
id: "lyraAttack",
value: "0.13"
},
{
id: "lyraDecay",
value: "0.05"
},
{
id: "lyraSustain",
value: "0.01"
},
{
id: "lyraRelease",
value: "0.22"
},
{
id: "lyraFxType",
value: "Chopper"
},
{
id: "lyraDelayTime",
value: "0.45"
},
{
id: "lyraDelayFeedback",
value: "0.2"
},
{
id: "lyraDelayMix",
value: "0.35"
},
{
id: "phaserRate",
value: "0.5"
},
{
id: "phaserDepth",
value: "1200"
},
{
id: "phaserFeedback",
value: "0.3"
},
{
id: "phaserMix",
value: "0.5"
},
{
id: "flangerRate",
value: "0.2"
},
{
id: "flangerDepth",
value: "0.005"
},
{
id: "flangerFeedback",
value: "0.5"
},
{
id: "flangerMix",
value: "0.5"
},
{
id: "quazarRate",
value: "2"
},
{
id: "quazarReso",
value: "25"
},
{
id: "quazarFreq",
value: "1500"
},
{
id: "quazarMix",
value: "1"
},
{
id: "chopperRate",
value: "1"
},
{
id: "chopperDepth",
value: "0.22"
},
{
id: "crusherBitDepth",
value: "8"
},
{
id: "crusherFreq",
value: "0.5"
},
{
id: "crusherMix",
value: "0.5"
},
{
id: "patchSource",
value: "LFO 1 (Slow)"
},
{
id: "patchDest",
value: "LYRA GATE PROB"
},
{
id: "patchAmount",
value: "0.7"
},
{
id: "globalModScale",
value: "1"
},
{
id: "kickPitch",
value: "35"
},
{
id: "kickDecay",
value: "0.15"
},
{
id: "kickDrive",
value: "0.39"
},
{
id: "bassOctave",
value: "-2"
},
{
id: "bassDetune",
value: "17"
},
{
id: "bassFilterCutoff",
value: "4332"
},
{
id: "bassFilterQ",
value: "8"
},
{
id: "snareTone",
value: "256"
},
{
id: "snareSnappy",
value: "2342"
},
{
id: "snareDecay",
value: "0.12"
},
{
id: "hatDecay",
value: "0.3"
},
{
id: "hatMetal",
value: "6807"
},
{
id: "masterDist",
value: "0.21"
},
{
id: "masterReverb",
value: "0.2"
}
],
patches: [
{
source: "LYRA ENV",
dest: "HHT METAL",
amount: "1.20",
modRangeMin: -1,
modRangeMax: 1
},
{
source: "LYRA LFO",
dest: "SD SNAPPY",
amount: "1.20",
modRangeMin: -1,
modRangeMax: 1
},
{
source: "S&H (Random)",
dest: "SD TONE",
amount: "1.20",
modRangeMin: -1,
modRangeMax: 1
},
{
source: "LFO 1 (Slow)",
dest: "BASS OCTAVE",
amount: "1.20",
modRangeMin: -1,
modRangeMax: 1
},
{
source: "CLK/2",
dest: "MASTER REVERB",
amount: "1.20",
modRangeMin: -1,
modRangeMax: 1
},
{
source: "LFO 2 (Fast)",
dest: "LYRA LFO RATE",
amount: "2.00",
modRangeMin: -1,
modRangeMax: 1
},
{
source: "CLK/4",
dest: "SD SNAPPY",
amount: "2.00",
modRangeMin: -1,
modRangeMax: 1
},
{
source: "LYRA LFO",
dest: "LYRA LFO DEPTH",
amount: "2.00",
modRangeMin: -1,
modRangeMax: 1
},
{
source: "LFO 1 (Slow)",
dest: "LYRA GATE PROB",
amount: "2.00",
modRangeMin: -1,
modRangeMax: 1
}
],
isSafetyOn: false,
sequences: {
kick: [
true,
false,
false,
false,
false,
false,
true,
false,
true,
false,
true,
false,
false,
false,
false,
false,
true,
false,
true,
true,
false,
false,
false,
false,
true,
true,
false,
true,
false,
false,
true,
false
],
snare: [
false,
false,
false,
true,
false,
false,
false,
true,
false,
false,
false,
false,
true,
false,
false,
false,
false,
false,
false,
false,
true,
false,
false,
true,
false,
false,
false,
false,
true,
false,
false,
false
],
hat: [
true,
true,
true,
true,
true,
true,
true,
true,
true,
true,
true,
true,
true,
true,
true,
true,
true,
true,
true,
true,
true,
true,
true,
true,
true,
true,
true,
true,
true,
true,
true,
true
],
bass: [
false,
false,
true,
true,
true,
false,
true,
true,
false,
true,
false,
true,
false,
false,
false,
true,
false,
true,
true,
false,
true,
false,
true,
true,
true,
false,
false,
false,
false,
true,
true,
false
],
lyraGate: [
true,
false,
true,
true,
true,
true,
true,
false,
true,
true,
true,
false,
true,
true,
true,
true,
true,
false,
true,
true,
true,
false,
true,
true,
true,
true,
true,
false,
true,
true,
true,
true
]
},
lyraSequencer: {
isPlaying: true,
steps: [
{
active: true,
note: 8,
slide: true,
accent: true
},
{
active: true,
note: 7,
slide: true,
accent: true
},
{
active: false,
note: 15,
slide: false,
accent: false
},
{
active: false,
note: 0,
slide: true,
accent: false
},
{
active: true,
note: 10,
slide: true,
accent: false
},
{
active: true,
note: 3,
slide: true,
accent: false
},
{
active: false,
note: 10,
slide: false,
accent: true
},
{
active: false,
note: 5,
slide: false,
accent: false
},
{
active: false,
note: 10,
slide: false,
accent: true
},
{
active: true,
note: 15,
slide: true,
accent: false
},
{
active: false,
note: 7,
slide: false,
accent: false
},
{
active: false,
note: 3,
slide: true,
accent: false
},
{
active: true,
note: 8,
slide: false,
accent: true
},
{
active: true,
note: 14,
slide: true,
accent: false
},
{
active: true,
note: 8,
slide: false,
accent: false
},
{
active: false,
note: 10,
slide: false,
accent: false
}
]
},
stepMeta: {
kick: {},
snare: {},
hat: {},
bass: {},
lyraGate: {}
},
version: 2
};
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment