A Pen by semanticentity on CodePen.
Created
August 30, 2025 02:27
-
-
Save semanticentity/7961c4d4ff9ee6d3aa023ae7f0e88513 to your computer and use it in GitHub Desktop.
WHITE DWARF
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <!DOCTYPE html> | |
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <title>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> |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // 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