A Pen by semanticentity on CodePen.
Created
September 16, 2025 19:31
-
-
Save semanticentity/51c4fa8e476846ef457cbe8fbde3069a 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: 678px; | |
| 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: 8px; | |
| margin-bottom: 5px; | |
| } | |
| .pattern-label { | |
| width: 30px; | |
| text-align: right; | |
| 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; | |
| transition: width 0.3s ease-in-out, height 0.3s ease-in-out; | |
| } | |
| #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: 959px) { | |
| #kaosPad { | |
| width: 60px; | |
| height: 60px; | |
| transition: width 0.3s ease-in-out, height 0.3s ease-in-out; | |
| } | |
| #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: 0.9em; | |
| letter-spacing: 2px; | |
| } | |
| #lyraKeyDisplay br { | |
| display: none; | |
| } | |
| #lyraKeyDisplay code { | |
| color: var(--accent-color); | |
| } | |
| .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: 769px) { | |
| #controls { | |
| padding: 10px; | |
| width: 98vw; | |
| left: 1vw; | |
| box-sizing: border-box; | |
| } | |
| #controls button { | |
| font-size: 12px; | |
| 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; | |
| height: 15px; | |
| min-width: 6px; | |
| } | |
| input#lyraPortamento { | |
| max-width: 80px; | |
| } | |
| label[for=lyraPortamento] { | |
| font-size: 8px; | |
| margin-right: -10px; | |
| } | |
| #lyraKeyDisplay br { | |
| display: block; | |
| } | |
| } | |
| /* 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; | |
| } | |
| /* --- ADDED FOR UX FEATURES --- */ | |
| @keyframes pulse-border { | |
| 0% { | |
| border-color: var(--border-color); | |
| } | |
| 50% { | |
| border-color: var(--accent-hover); | |
| } | |
| 100% { | |
| border-color: var(--border-color); | |
| } | |
| } | |
| .row.is-modulated>label { | |
| color: var(--accent-hover); | |
| text-shadow: 0 0 5px var(--shadow-color); | |
| } | |
| .row.is-modulated>input, | |
| .row.is-modulated>select { | |
| animation: pulse-border 2s infinite ease-in-out; | |
| } | |
| .track-controls { | |
| display: flex; | |
| gap: 4px; | |
| } | |
| .track-controls button { | |
| font-size: 10px; | |
| padding: 3px 6px; | |
| min-width: 22px; | |
| flex-grow: 0; | |
| font-family: 'Roboto Mono', monospace; | |
| font-weight: bold; | |
| } | |
| .mute-btn.active { | |
| background-color: #ff4136; | |
| color: white; | |
| } | |
| .solo-btn.active { | |
| background-color: #39CCCC; | |
| color: white; | |
| } | |
| .track-length { | |
| width: 15px; | |
| padding: 4px; | |
| font-size: 11px; | |
| background-color: #222; | |
| color: var(--text-color); | |
| border: 1px solid var(--border-color); | |
| border-radius: 2px; | |
| text-align: center; | |
| -moz-appearance: textfield; | |
| } | |
| .track-length::-webkit-outer-spin-button, | |
| .track-length::-webkit-inner-spin-button { | |
| -webkit-appearance: none; | |
| margin: 0; | |
| } | |
| .pattern-row .step.disabled { | |
| background-color: #1a1a1a; | |
| border-color: #333; | |
| opacity: 0.5; | |
| cursor: not-allowed; | |
| } | |
| </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" data-default="0.4"> | |
| <span class="param-value" style="min-width: 40px;">0.40</span> | |
| </div> | |
| <div class="row"> | |
| <label for="bpm">BPM:</label> | |
| <input type="range" id="bpm" min="60" max="190" value="138" data-default="138"> | |
| <span id="bpmValue" style="min-width: 30px;">138</span> | |
| </div> | |
| <!-- === MODIFIED: SEED Block with Random Seed Generator === --> | |
| <div class="row"> | |
| <label for="seedInput">SEED:</label> | |
| <input type="text" id="seedInput" placeholder="Enter text or leave blank for random..."> | |
| <button id="randomSeedButton" title="Generate Random Seed" style="flex-grow: 0; font-size: 1.2em; padding: 8px 12px;">🎲</button> | |
| <button id="generateFromSeedButton" style="flex-grow: 0.7;">GENERATE</button> | |
| </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="track-controls"> | |
| <button class="mute-btn" data-track="kick" title="Mute Track">M</button> | |
| <button class="solo-btn" data-track="kick" title="Solo Track">S</button> | |
| </div> | |
| <input type="number" class="track-length" min="1" max="32" value="32" data-track="kick" title="Track Length"> | |
| <div class="steps-container"></div> | |
| </div> | |
| <div class="pattern-row" data-type="snare"> | |
| <span class="pattern-label">SD:</span> | |
| <div class="track-controls"> | |
| <button class="mute-btn" data-track="snare" title="Mute Track">M</button> | |
| <button class="solo-btn" data-track="snare" title="Solo Track">S</button> | |
| </div> | |
| <input type="number" class="track-length" min="1" max="32" value="32" data-track="snare" title="Track Length"> | |
| <div class="steps-container"></div> | |
| </div> | |
| <div class="pattern-row" data-type="hat"> | |
| <span class="pattern-label">HHT:</span> | |
| <div class="track-controls"> | |
| <button class="mute-btn" data-track="hat" title="Mute Track">M</button> | |
| <button class="solo-btn" data-track="hat" title="Solo Track">S</button> | |
| </div> | |
| <input type="number" class="track-length" min="1" max="32" value="32" data-track="hat" title="Track Length"> | |
| <div class="steps-container"></div> | |
| </div> | |
| <div class="pattern-row" data-type="bass"> | |
| <span class="pattern-label">BASS:</span> | |
| <div class="track-controls"> | |
| <button class="mute-btn" data-track="bass" title="Mute Track">M</button> | |
| <button class="solo-btn" data-track="bass" title="Solo Track">S</button> | |
| </div> | |
| <input type="number" class="track-length" min="1" max="32" value="32" data-track="bass" title="Track Length"> | |
| <div class="steps-container"></div> | |
| </div> | |
| <div class="pattern-row" data-type="lyraGate"> | |
| <span class="pattern-label">SYNTH:</span> | |
| <div class="track-controls"> | |
| <button class="mute-btn" data-track="synth" title="Mute Track">M</button> | |
| <button class="solo-btn" data-track="synth" title="Solo Track">S</button> | |
| </div> | |
| <input type="number" class="track-length" min="1" max="32" value="32" data-track="lyraGate" title="Track Length"> | |
| <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" data-default="1"> | |
| <span class="param-value" style="min-width: 40px;">1.00</span> | |
| </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" data-default="0.8"> | |
| <span class="param-value" style="min-width: 40px;">0.80</span> | |
| </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" data-default="0.8"> | |
| <span class="param-value" style="min-width: 40px;">0.80</span> | |
| </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" data-default="0.8"> | |
| <span class="param-value" style="min-width: 40px;">0.80</span> | |
| </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" data-default="0.7"> | |
| <span class="param-value" style="min-width: 40px;">0.70</span> | |
| </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</button> | |
| <button id="exportStateButton">EXPORT</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" data-default="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"> | |
| Black Keys: <code>WETYUO</code> | |
| <br> | |
| White Keys: <code>ASDFGHJKL</code> | |
| </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" data-default="1"> | |
| <span class="param-value" style="min-width: 40px;">1</span> | |
| </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" data-default="0"> | |
| <span class="param-value" style="min-width: 40px;">0</span> | |
| </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" data-default="3"> | |
| <span class="param-value" style="min-width: 40px;">3</span> | |
| </div> | |
| <div class="row"> | |
| <label for="lyraFMAmt">FM AMT:</label> | |
| <input type="range" id="lyraFMAmt" min="0" max="2000" value="256" data-default="256"> | |
| <span class="param-value" style="min-width: 40px;">256</span> | |
| </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" data-default="0.03"> | |
| <span class="param-value" style="min-width: 40px;">0.03</span> | |
| </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" data-default="0.04"> | |
| <span class="param-value" style="min-width: 40px;">0.04</span> | |
| </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" data-default="3845"> | |
| <span class="param-value" style="min-width: 40px;">3845</span> | |
| </div> | |
| <div class="row"> | |
| <label for="lyraFilterQ">RESO:</label> | |
| <input type="range" id="lyraFilterQ" min="0" max="18" step="0.1" value="11.1" data-default="11.1"> | |
| <span class="param-value" style="min-width: 40px;">11.1</span> | |
| </div> | |
| <div class="row"> | |
| <label for="lyraFilterEnv">ENV AMT:</label> | |
| <input type="range" id="lyraFilterEnv" min="-5000" max="5000" value="-752" data-default="-752"> | |
| <span class="param-value" style="min-width: 40px;">-752</span> | |
| </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" data-default="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" data-default="1524"> | |
| <span class="param-value" style="min-width: 40px;">1524</span> | |
| </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" data-default="0.09"> | |
| <span class="param-value" style="min-width: 40px;">0.09</span> | |
| </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" data-default="0.05"> | |
| <span class="param-value" style="min-width: 40px;">0.05</span> | |
| </div> | |
| <div class="row"> | |
| <label for="lyraSustain">SUSTAIN:</label> | |
| <input type="range" id="lyraSustain" min="0" max="1" step="0.01" value="0" data-default="0"> | |
| <span class="param-value" style="min-width: 40px;">0.00</span> | |
| </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" data-default="0.25"> | |
| <span class="param-value" style="min-width: 40px;">0.25</span> | |
| </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" data-default="0.45"> | |
| <span class="param-value" style="min-width: 40px;">0.45</span> | |
| </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" data-default="0.2"> | |
| <span class="param-value" style="min-width: 40px;">0.20</span> | |
| </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" data-default="0.35"> | |
| <span class="param-value" style="min-width: 40px;">0.35</span> | |
| </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" data-default="0.5"> | |
| <span class="param-value" style="min-width: 40px;">0.5</span> | |
| </div> | |
| <div class="row"> | |
| <label for="phaserDepth">DEPTH:</label> | |
| <input type="range" id="phaserDepth" min="100" max="5000" step="10" value="1200" data-default="1200"> | |
| <span class="param-value" style="min-width: 40px;">1200</span> | |
| </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" data-default="0.3"> | |
| <span class="param-value" style="min-width: 40px;">0.30</span> | |
| </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" data-default="0.5"> | |
| <span class="param-value" style="min-width: 40px;">0.50</span> | |
| </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" data-default="0.2"> | |
| <span class="param-value" style="min-width: 40px;">0.20</span> | |
| </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" data-default="0.005"> | |
| <span class="param-value" style="min-width: 40px;">0.005</span> | |
| </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" data-default="0.5"> | |
| <span class="param-value" style="min-width: 40px;">0.50</span> | |
| </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" data-default="0.5"> | |
| <span class="param-value" style="min-width: 40px;">0.50</span> | |
| </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" data-default="2"> | |
| <span class="param-value" style="min-width: 40px;">2.0</span> | |
| </div> | |
| <div class="row"> | |
| <label for="quazarReso">RESO (Q):</label> | |
| <input type="range" id="quazarReso" min="5" max="50" step="0.5" value="25" data-default="25"> | |
| <span class="param-value" style="min-width: 40px;">25.0</span> | |
| </div> | |
| <div class="row"> | |
| <label for="quazarFreq">CENTER HZ:</label> | |
| <input type="range" id="quazarFreq" min="200" max="8000" step="10" value="1500" data-default="1500"> | |
| <span class="param-value" style="min-width: 40px;">1500</span> | |
| </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" data-default="1.0"> | |
| <span class="param-value" style="min-width: 40px;">1.00</span> | |
| </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" data-default="0.05"> | |
| <span class="param-value" style="min-width: 40px;">0.05</span> | |
| </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" data-default="8"> | |
| <span class="param-value" style="min-width: 40px;">8</span> | |
| </div> | |
| <div class="row"> | |
| <label for="crusherFreq">DOWNSAMPLE:</label> | |
| <input type="range" id="crusherFreq" min="0" max="1" step="0.01" value="0.5" data-default="0.5"> | |
| <span class="param-value" style="min-width: 40px;">0.50</span> | |
| </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" data-default="0.5"> | |
| <span class="param-value" style="min-width: 40px;">0.50</span> | |
| </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" data-default="0.5" style="width: 280px;"> | |
| <span class="param-value" style="min-width: 40px;">0.50</span> | |
| </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'));}); app.syncUiToAudioState();">RAND</button></h4> | |
| <div class="row"> | |
| <label>PITCH:</label> | |
| <input type="range" id="kickPitch" min="20" max="100" value="51" data-default="51"> | |
| <span class="param-value" style="min-width: 40px;">51</span> | |
| </div> | |
| <div class="row"> | |
| <label>DECAY:</label> | |
| <input type="range" id="kickDecay" min="0.05" max="0.5" step="0.01" value="0.17" data-default="0.17"> | |
| <span class="param-value" style="min-width: 40px;">0.17</span> | |
| </div> | |
| <div class="row"> | |
| <label>DRIVE:</label> | |
| <input type="range" id="kickDrive" min="0" max="1" step="0.01" value="0.39" data-default="0.39"> | |
| <span class="param-value" style="min-width: 40px;">0.39</span> | |
| </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'));}); app.syncUiToAudioState();">RAND</button></h4> | |
| <div class="row"> | |
| <label>OCTAVE:</label> | |
| <input type="range" id="bassOctave" min="-2" max="2" value="-1" step="1" data-default="-1"> | |
| <span class="param-value" style="min-width: 40px;">-1</span> | |
| </div> | |
| <div class="row"> | |
| <label>DETUNE:</label> | |
| <input type="range" id="bassDetune" min="0" max="25" value="13" data-default="13"> | |
| <span class="param-value" style="min-width: 40px;">13</span> | |
| </div> | |
| <div class="row"> | |
| <label>FILTER:</label> | |
| <input type="range" id="bassFilterCutoff" min="100" max="8000" value="2265" data-default="2265"> | |
| <span class="param-value" style="min-width: 40px;">2265</span> | |
| </div> | |
| <div class="row"> | |
| <label>RESO:</label> | |
| <input type="range" id="bassFilterQ" min="0" max="20" value="17" data-default="17"> | |
| <span class="param-value" style="min-width: 40px;">17</span> | |
| </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'));}); app.syncUiToAudioState();">RAND</button></h4> | |
| <div class="row"> | |
| <label>TONE:</label> | |
| <input type="range" id="snareTone" min="100" max="500" value="129" data-default="129"> | |
| <span class="param-value" style="min-width: 40px;">129</span> | |
| </div> | |
| <div class="row"> | |
| <label>SNAPPY:</label> | |
| <input type="range" id="snareSnappy" min="1000" max="9000" value="5245" data-default="5245"> | |
| <span class="param-value" style="min-width: 40px;">5245</span> | |
| </div> | |
| <div class="row"> | |
| <label>DECAY:</label> | |
| <input type="range" id="snareDecay" min="0.05" max="0.4" step="0.01" value="0.25" data-default="0.25"> | |
| <span class="param-value" style="min-width: 40px;">0.25</span> | |
| </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'));}); app.syncUiToAudioState();">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" data-default="0.16"> | |
| <span class="param-value" style="min-width: 40px;">0.16</span> | |
| </div> | |
| <div class="row"> | |
| <label>METAL:</label> | |
| <input type="range" id="hatMetal" min="5000" max="15000" value="7571" data-default="7571"> | |
| <span class="param-value" style="min-width: 40px;">7571</span> | |
| </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" data-default="0.03"> | |
| <span class="param-value" style="min-width: 40px;">0.03</span> | |
| </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" data-default="0.44"> | |
| <span class="param-value" style="min-width: 40px;">0.44</span> | |
| </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 TRACK</h4> | |
| <textarea id="importTextarea" placeholder="Paste a Share Code or full Preset Code 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; // DEPRECATED - now per-track | |
| this.masterGain = this.audioCtx.createGain(); | |
| this.masterGain.gain.value = 0.4; | |
| 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(); | |
| 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; | |
| this.lastLoadedPresetState = null; | |
| // --- NEW: Per-track Mute/Solo state --- | |
| this.trackState = { | |
| kick: { muted: false, soloed: false, gain: this.kickGain }, | |
| snare: { muted: false, soloed: false, gain: this.snareGain }, | |
| hat: { muted: false, soloed: false, gain: this.hatGain }, | |
| bass: { muted: false, soloed: false, gain: this.bassGain }, | |
| synth: { muted: false, soloed: false, gain: this.synthGain }, | |
| }; | |
| // --- NEW: Per-track sequence length state --- | |
| this.sequenceLengths = { | |
| kick: 32, | |
| snare: 32, | |
| hat: 32, | |
| bass: 32, | |
| lyraGate: 32 | |
| }; | |
| 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); | |
| 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 | |
| } | |
| }; | |
| this.modsFrozen = false; | |
| this.isMuted = false; | |
| 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 = { | |
| '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), | |
| 'HHT DECAY': v => this.params.hatDecay = .01 + Math.max(0, v) * .4, | |
| 'HHT METAL': v => this.params.hatMetal = Math.max(100, 8000 + v * 7000), | |
| 'SD SNAPPY': v => this.params.snareSnappy = Math.max(100, 5000 + v * 4000), | |
| 'SD TONE': v => this.params.snareTone = Math.max(20, 220 + v * 200), | |
| '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), | |
| 'BASS OCTAVE': v => this.params.bassOctave = Math.round(v * 2), | |
| 'BASS RESO': v => this.params.bassFilterQ = Math.max(0, v) * 25, | |
| '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 DETUNE': v => this.updateLyraParam('detune', v * 50), | |
| 'LYRA DRIVE': v => this.updateLyraParam('drive', Math.max(0, v) * parseFloat(document.getElementById('lyraDrive').max)), | |
| '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)), | |
| 'LYRA FILTER ENV': v => this.updateLyraParam('filterEnv', v * 5000), | |
| 'LYRA RESO': v => this.updateLyraParam('filterQ', Math.max(0, v) * parseFloat(document.getElementById('lyraFilterQ').max)), | |
| "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)), | |
| '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) | |
| }, | |
| '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); | |
| }, | |
| '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); | |
| }, | |
| '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); | |
| }, | |
| 'QUAZAR RATE': v => { | |
| if (this.lyraFxNodes.quazar) this.lyraFxNodes.quazar.lfo.frequency.setTargetAtTime(Math.max(0.1, v * 15), this.audioCtx.currentTime, 0.01); | |
| }, | |
| 'QUAZAR RESO': v => { | |
| if (this.lyraFxNodes.quazar) this.lyraFxNodes.quazar.filter.Q.setTargetAtTime(Math.max(0, v) * 50, this.audioCtx.currentTime, 0.01); | |
| }, | |
| '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) { | |
| const initialLength = this.patches.length; | |
| this.patches = this.patches.filter(p => p.id !== id); | |
| return this.patches.length < initialLength; | |
| } | |
| 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'; | |
| e.dataset.patchId = p.id; // Store ID for easier lookup | |
| const textSpan = document.createElement('span'); | |
| textSpan.textContent = `${p.source} → ${p.dest} (${p.amount})`; | |
| e.appendChild(textSpan); | |
| const b = document.createElement('button'); | |
| b.textContent = 'X'; | |
| 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 | |
| }; | |
| } | |
| updateTrackGains() { | |
| const anySolo = Object.values(this.trackState).some(t => t.soloed); | |
| for (const track of Object.values(this.trackState)) { | |
| const isMuted = track.muted; | |
| const isSoloed = track.soloed; | |
| if (anySolo) { | |
| track.gain.gain.setTargetAtTime(isSoloed ? 1.0 : 0.0, this.audioCtx.currentTime, 0.01); | |
| } else { | |
| track.gain.gain.setTargetAtTime(isMuted ? 0.0 : 1.0, this.audioCtx.currentTime, 0.01); | |
| } | |
| } | |
| } | |
| playKick(t, velocity = 1.0) { | |
| const g = this.audioCtx.createGain(); | |
| g.connect(this.kickGain); | |
| 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); | |
| 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); | |
| 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.sequenceLengths.bass) % this.sequenceLengths.bass; | |
| 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); | |
| if (isWobbleStep) { | |
| f.Q.value = 20; | |
| if (!prevStepWasWobble) { | |
| if (this.bassLfo && this.bassLfo.lfo) { | |
| this.bassLfo.lfo.stop(t); | |
| } | |
| const newLfo = this.audioCtx.createOscillator(); | |
| newLfo.type = 'sine'; | |
| 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); | |
| } | |
| if (this.bassLfo) { | |
| const clampedRatchet = Math.min(meta.ratchet, 4); | |
| const lfoBaseRate = 2; | |
| const lfoSpeed = lfoBaseRate * clampedRatchet; | |
| this.bassLfo.lfo.frequency.setTargetAtTime(lfoSpeed, t, 0.01); | |
| const filterBaseFreq = 100; | |
| f.frequency.setValueAtTime(filterBaseFreq, t); | |
| const lfoDepth = Math.max(0, this.params.bassFilterCutoff - filterBaseFreq); | |
| this.bassLfo.lfoGain.gain.setTargetAtTime(lfoDepth, t, 0.01); | |
| } | |
| } else { | |
| f.Q.value = this.params.bassFilterQ; | |
| f.frequency.value = this.params.bassFilterCutoff; | |
| if (prevStepWasWobble) { | |
| if (this.bassLfo && this.bassLfo.lfo) { | |
| this.bassLfo.lfo.stop(t); | |
| this.bassLfo = null; | |
| } | |
| } | |
| } | |
| const singleStepDur = (60 / this.bpm) / 4; | |
| let dur; | |
| if (isWobbleStep && meta.ratchet >= 3) { | |
| dur = singleStepDur * 2; | |
| } else { | |
| dur = singleStepDur; | |
| } | |
| 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 { | |
| 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'); | |
| } | |
| const lyraGateLength = this.sequenceLengths.lyraGate; | |
| const currentGateStep = this.masterCycleStep % lyraGateLength; | |
| const stepIsGated = this.sequences.lyraGate[currentGateStep]; | |
| 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': 4, 'KeyF': 5, 'KeyG': 7, 'KeyH': 9, 'KeyJ': 11, 'KeyK': 12, 'KeyL': 14, | |
| 'KeyW': 1, 'KeyE': 3, 'KeyT': 6, 'KeyY': 8, 'KeyU': 10, 'KeyO': 13, | |
| }; | |
| 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; | |
| } | |
| } | |
| } | |
| 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) { | |
| element.dispatchEvent(new Event('input', { bubbles: true })); | |
| if (element.tagName === 'SELECT') { | |
| element.dispatchEvent(new Event('change', { bubbles: true })); | |
| } | |
| } | |
| } | |
| } | |
| updateModulation() { | |
| if (this.isMuted || this.modsFrozen || (!this.isPlaying && !this.lyraVoices.some(v => v.isActive) && !this.lyraSequencer.isPlaying)) { | |
| requestAnimationFrame(this.updateModulation); | |
| return; | |
| } | |
| const lfo1 = this.modSources['LFO 1 (Slow)']; lfo1.phase += 0.05 * (this.bpm / 120); lfo1.value = Math.sin(lfo1.phase * 0.1); | |
| const lfo2 = this.modSources['LFO 2 (Fast)']; lfo2.phase += 0.2 * (this.bpm / 120); lfo2.value = Math.sin(lfo2.phase); | |
| const lfo3 = this.modSources['LFO 1 (Slower)']; lfo3.phase += lfo3.rate * (this.bpm / 120); lfo3.value = Math.sin(lfo3.phase); | |
| const lfo4 = this.modSources['LFO 2 (Faster)']; lfo4.phase += lfo4.rate * (this.bpm / 120); lfo4.value = Math.sin(lfo4.phase); | |
| this.modSources['LFO MIX'].value = (lfo1.value * 0.1 + lfo2.value * 0.2 + lfo3.value * 0.4 + lfo4.value * 0.3); | |
| 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; | |
| 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; | |
| d(scaledValue * p.amount * scale); | |
| } | |
| }); | |
| requestAnimationFrame(this.updateModulation); | |
| } | |
| scheduler() { | |
| if (!this.isPlaying) return; | |
| while (this.nextNoteTime < this.audioCtx.currentTime + this.scheduleAheadTime) { | |
| const masterStep = this.masterCycleStep; | |
| const currentPass = (masterStep < 32) ? 'first' : 'second'; | |
| const randomSourceValue = this.modSources['S&H (Random)'].value; | |
| if (masterStep % 2 === 0) this.modSources['S&H (2 steps)'].value = randomSourceValue; | |
| if (masterStep % 4 === 0) this.modSources['S&H (4 steps)'].value = randomSourceValue; | |
| if (masterStep % 8 === 0) this.modSources['S&H (8 steps)'].value = randomSourceValue; | |
| if (masterStep % 16 === 0) this.modSources['S&H (16 steps)'].value = randomSourceValue; | |
| if (masterStep % 32 === 0) this.modSources['S&H (32 steps)'].value = randomSourceValue; | |
| if (masterStep % 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 => { | |
| const trackLength = this.sequenceLengths[type]; | |
| const currentStepInPattern = masterStep % trackLength; | |
| 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 = masterStep % this.lyraSequencer.stepLength; | |
| const lyraGateLength = this.sequenceLengths.lyraGate; | |
| const currentGateStep = masterStep % lyraGateLength; | |
| const lyraGateMeta = (this.stepMeta.lyraGate && this.stepMeta.lyraGate[currentGateStep]) || {}; | |
| 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 = 32; | |
| if (direction > 0) { | |
| seq.unshift(seq.pop()); | |
| } else { | |
| seq.push(seq.shift()); | |
| } | |
| 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 = 32 / 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 < 32; 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(rng = Math.random) { | |
| 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: rng() > 0.4, | |
| note: notes[Math.floor(rng() * notes.length)], | |
| slide: rng() > 0.7, | |
| accent: rng() > 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(rng = Math.random) { | |
| const randomPick = (arr) => arr[Math.floor(rng() * arr.length)]; | |
| const randomRange = (min, max) => min + rng() * (max - min); | |
| const r = { | |
| osc1Type: randomPick(['sine', 'square', 'sawtooth', 'triangle']), | |
| osc1Oct: Math.floor(randomRange(-2, 3)), | |
| osc2Type: randomPick(['sine', 'square', 'sawtooth', 'triangle']), | |
| osc2Oct: Math.floor(randomRange(-2, 3)), | |
| 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.1), | |
| decay: randomRange(0.1, 0.5), | |
| sustain: randomRange(0.1, 0.7), | |
| release: randomRange(0.1, 0.5), | |
| portamento: randomRange(0, 200) | |
| }; | |
| for (const [k, v] of Object.entries(r)) { | |
| this.updateLyraParam(k, k === 'portamento' ? v / 1000 : v); | |
| } | |
| this.updateLyraParam('all'); | |
| } | |
| randomizeDrumModules(rng = Math.random) { | |
| const rand = (min, max) => min + rng() * (max - min); | |
| this.params.kickPitch = rand(20, 100); | |
| this.params.kickDecay = rand(0.05, 0.5); | |
| this.params.kickDrive = rand(0, 1); | |
| this.params.bassOctave = Math.floor(rand(-2, 3)); | |
| this.params.bassDetune = rand(0, 25); | |
| this.params.bassFilterCutoff = rand(100, 8000); | |
| this.params.bassFilterQ = rand(0, 20); | |
| this.params.snareTone = rand(100, 500); | |
| this.params.snareSnappy = rand(1000, 9000); | |
| this.params.snareDecay = rand(0.05, 0.4); | |
| this.params.hatDecay = rand(0.01, 0.3); | |
| this.params.hatMetal = rand(5000, 15000); | |
| } | |
| randomizePatches(rng = Math.random) { | |
| this.patches = []; | |
| const numPatches = Math.floor(3 + rng() * 4); | |
| const sources = this.getModSources(); | |
| const dests = this.getModDestinations(); | |
| const usedCombos = new Set(); | |
| for (let i = 0; i < numPatches; i++) { | |
| let source, dest, combo; | |
| let attempts = 0; | |
| do { | |
| source = sources[Math.floor(rng() * sources.length)]; | |
| dest = dests[Math.floor(rng() * dests.length)]; | |
| combo = `${source}->${dest}`; | |
| attempts++; | |
| } while (usedCombos.has(combo) && attempts < 50); | |
| if (attempts < 50) { | |
| usedCombos.add(combo); | |
| const amount = (rng() * 4 - 2).toFixed(2); | |
| this.addPatch(source, dest, parseFloat(amount)); | |
| } | |
| } | |
| } | |
| updateLooperDisplay() { | |
| const masterStep = this.masterCycleStep; | |
| const currentPass = (masterStep < 32) ? 'first' : 'second'; | |
| document.querySelectorAll('.pattern-row .step.current').forEach(e => e.classList.remove('current')); | |
| for (const type in this.sequences) { | |
| const container = document.querySelector(`.pattern-row[data-type="${type}"]`); | |
| if (!container) continue; | |
| const trackLength = this.sequenceLengths[type] || 32; | |
| const currentStepInPattern = masterStep % trackLength; | |
| const currentStepEl = container.querySelector(`.step[data-index='${currentStepInPattern}']`); | |
| if (currentStepEl) { | |
| currentStepEl.classList.add('current'); | |
| } | |
| 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; | |
| this.stopAllLyraNotes(); | |
| if (this.lyraLooper.isPlaying) { | |
| this.toggleLyraPlay(); | |
| } | |
| const masterGainNode = this.masterGain; | |
| masterGainNode.gain.cancelScheduledValues(now); | |
| masterGainNode.gain.setTargetAtTime(0, now, 0.015); | |
| masterGainNode.gain.setTargetAtTime(0.4, now + 0.02, 0.01); | |
| this.patches.forEach(patch => { | |
| if (patch.isLocked) { | |
| const destFunction = this.modDestinationsSafe[patch.dest]; | |
| const controlElement = document.getElementById(Object.keys(this.modDestinationsSafe).find(key => this.modDestinationsSafe[key] === destFunction)); | |
| if (controlElement) { | |
| 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(); | |
| 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(rng = Math.random) { | |
| const k = Array(32).fill(false), | |
| s = Array(32).fill(false); | |
| s[4] = true; | |
| s[12] = true; | |
| s[20] = true; | |
| s[28] = true; | |
| if (rng() < .15) s[12] = false; | |
| if (rng() < .15) s[28] = false; | |
| if (this.bpm < 135) { | |
| k[0] = true; | |
| k[8] = true; | |
| k[16] = true; | |
| k[24] = true; | |
| if (rng() > .6) k[6] = true; | |
| if (rng() > .5) k[14] = true; | |
| if (rng() > .7) k[22] = true; | |
| if (rng() > .4) k[30] = true; | |
| } else { | |
| const p = rng(); | |
| if (p < .7) { | |
| k[0] = true; | |
| k[8] = true; | |
| k[16] = true; | |
| k[24] = true; | |
| if (rng() > .4) k[3] = true; | |
| if (rng() > .6) k[13] = true; | |
| if (rng() > .4) k[19] = true; | |
| if (rng() > .6) k[29] = true; | |
| } else { | |
| k[0] = true; | |
| if (rng() > .2) k[6] = true; | |
| k[11] = true; | |
| k[16] = true; | |
| if (rng() > .3) k[22] = true; | |
| k[27] = true; | |
| } | |
| } | |
| this.sequences.kick = k; | |
| this.sequences.snare = s; | |
| this.sequences.hat = this.sequences.hat.map(() => rng() > .4); | |
| this.sequences.bass = this.sequences.bass.map(() => rng() > .7); | |
| this.sequences.lyraGate = this.sequences.lyraGate.map(() => rng() > .2); | |
| this.stepMeta = { | |
| kick: {}, | |
| snare: {}, | |
| hat: {}, | |
| bass: {}, | |
| lyraGate: {} | |
| }; | |
| this.updatePatternEditor(); | |
| } | |
| createPrng(seedString) { | |
| function stringToSeed(str) { | |
| let hash = 0; | |
| for (let i = 0; i < str.length; i++) { | |
| const char = str.charCodeAt(i); | |
| hash = ((hash << 5) - hash) + char; | |
| hash |= 0; | |
| } | |
| return hash; | |
| } | |
| let seed = stringToSeed(seedString); | |
| return function() { | |
| let t = seed += 0x6D2B79F5; | |
| t = Math.imul(t ^ t >>> 15, t | 1); | |
| t ^= t + Math.imul(t ^ t >>> 7, t | 61); | |
| return ((t ^ t >>> 14) >>> 0) / 4294967296; | |
| } | |
| } | |
| generateFromSeed(seedString) { | |
| const seededRandom = this.createPrng(seedString); | |
| this.randomizeSequences(seededRandom); | |
| this.randomizeLyraSettings(seededRandom); | |
| this.randomizeLyraSequence(seededRandom); | |
| this.randomizeDrumModules(seededRandom); | |
| this.randomizePatches(seededRandom); | |
| const bpmSlider = document.getElementById('bpm'); | |
| const swingSlider = document.getElementById('swing'); | |
| bpmSlider.value = 90 + seededRandom() * 80; | |
| swingSlider.value = seededRandom() * 0.75; | |
| bpmSlider.dispatchEvent(new Event('input')); | |
| swingSlider.dispatchEvent(new Event('input')); | |
| } | |
| 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'); | |
| } | |
| }); | |
| } | |
| } | |
| updateVisibleStepsUI() { | |
| document.querySelectorAll('.pattern-row').forEach(row => { | |
| const type = row.dataset.type; | |
| const length = this.sequenceLengths[type] || 32; | |
| row.querySelectorAll('.step').forEach((step, index) => { | |
| step.classList.toggle('disabled', index >= length); | |
| }); | |
| }); | |
| } | |
| 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.sequenceLengths = this.sequenceLengths; | |
| state.lyraSequencer = { | |
| isPlaying: this.lyraSequencer.isPlaying, | |
| steps: this.lyraSequencer.steps | |
| }; | |
| state.stepMeta = this.stepMeta; | |
| state.version = this.presetVersion; | |
| return state; | |
| } | |
| setState(state) { | |
| this.lastLoadedPresetState = state; | |
| this.reloadUntil = performance.now() + 600; | |
| this.isSafetyOn = state.isSafetyOn ?? false; | |
| 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 | |
| }; | |
| } | |
| const defaultLengths = { kick: 32, snare: 32, hat: 32, bass: 32, lyraGate: 32 }; | |
| this.sequenceLengths = { ...defaultLengths, ...(state.sequenceLengths || {}) }; | |
| for (const track in this.sequenceLengths) { | |
| const input = document.querySelector(`.track-length[data-track="${track}"]`); | |
| if (input) { | |
| input.value = this.sequenceLengths[track]; | |
| } | |
| } | |
| this.updateVisibleStepsUI(); | |
| 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 ?? true; // Default to true if not present | |
| 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: `whitedwarf-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.modWatcherInterval = null; // For modulation feedback | |
| this.init(); | |
| } | |
| syncUiToAudioState() { | |
| const audio = this.audioSystem; | |
| const paramToElementIdMap = { | |
| kickPitch: 'kickPitch', kickDecay: 'kickDecay', kickDrive: 'kickDrive', | |
| bassOctave: 'bassOctave', bassDetune: 'bassDetune', bassFilterCutoff: 'bassFilterCutoff', bassFilterQ: 'bassFilterQ', | |
| snareTone: 'snareTone', snareSnappy: 'snareSnappy', snareDecay: 'snareDecay', | |
| hatDecay: 'hatDecay', hatMetal: 'hatMetal', | |
| osc1Type: 'lyraOsc1Type', osc1Oct: 'lyraOsc1Oct', | |
| osc2Type: 'lyraOsc2Type', osc2Oct: 'lyraOsc2Oct', | |
| detune: 'lyraDetune', fmAmount: 'lyraFMAmt', waveFold: 'lyraWaveFold', | |
| driveMode: 'lyraDriveMode', drive: 'lyraDrive', | |
| filterType: 'lyraFilterType', filterCutoff: 'lyraFilterCutoff', filterQ: 'lyraFilterQ', filterEnv: 'lyraFilterEnv', | |
| lfoDepth: 'lyraLfoDepth', | |
| attack: 'lyraAttack', decay: 'lyraDecay', sustain: 'lyraSustain', release: 'lyraRelease', | |
| lfoRate: 'lyraLfoRate', portamento: 'lyraPortamento', | |
| }; | |
| for (const param in audio.params) { | |
| const elementId = paramToElementIdMap[param]; | |
| if (elementId) { | |
| const el = document.getElementById(elementId); | |
| if (el) el.value = audio.params[param]; | |
| } | |
| } | |
| for (const param in audio.lyraParams) { | |
| const elementId = paramToElementIdMap[param]; | |
| if (elementId) { | |
| const el = document.getElementById(elementId); | |
| if (el) { | |
| if (param === 'portamento') { | |
| el.value = audio.lyraParams[param] * 1000; | |
| } else { | |
| el.value = audio.lyraParams[param]; | |
| } | |
| } | |
| } | |
| } | |
| document.getElementById('controls').dispatchEvent(new Event('input', { bubbles: true })); | |
| } | |
| dispose() { | |
| try { | |
| if (this.modWatcherInterval) { | |
| clearInterval(this.modWatcherInterval); | |
| } | |
| 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; | |
| } | |
| 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; | |
| } | |
| } | |
| 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'); | |
| this.updateModulationHighlights(); | |
| 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)); | |
| } | |
| } | |
| updateModulationHighlights() { | |
| const modulatedDests = new Set(this.audioSystem.patches.map(p => p.dest)); | |
| const destToElementIdMap = { | |
| 'BD PITCH': 'kickPitch', 'BD DECAY': 'kickDecay', 'BD DRIVE': 'kickDrive', | |
| 'BASS OCTAVE': 'bassOctave', 'BASS DETUNE': 'bassDetune', 'BASS FILTER': 'bassFilterCutoff', 'BASS RESO': 'bassFilterQ', | |
| 'SD TONE': 'snareTone', 'SD SNAPPY': 'snareSnappy', 'SNARE DECAY': 'snareDecay', | |
| 'HHT DECAY': 'hatDecay', 'HHT METAL': 'hatMetal', | |
| 'MASTER DIST': 'masterDist', 'MASTER REVERB': 'masterReverb', | |
| 'LYRA OSC1 OCT': 'lyraOsc1Oct', 'LYRA OSC2 OCT': 'lyraOsc2Oct', 'LYRA DETUNE': 'lyraDetune', | |
| 'LYRA FM AMT': 'lyraFMAmt', 'LYRA FOLD': 'lyraWaveFold', 'LYRA DRIVE': 'lyraDrive', | |
| 'LYRA FILTER': 'lyraFilterCutoff', 'LYRA RESO': 'lyraFilterQ', 'LYRA FILTER ENV': 'lyraFilterEnv', | |
| 'LYRA LFO DEPTH': 'lyraLfoDepth', 'LYRA LFO RATE': 'lyraLfoRate', | |
| 'LYRA ATTACK': 'lyraAttack', 'LYRA DECAY': 'lyraDecay', 'LYRA SUSTAIN': 'lyraSustain', 'LYRA RELEASE': 'lyraRelease', | |
| 'LYRA SLIDE': 'lyraPortamento', | |
| 'LYRA DELAY TIME': 'lyraDelayTime', 'LYRA DELAY FB': 'lyraDelayFeedback', 'LYRA DELAY MIX': 'lyraDelayMix', | |
| 'PHASER RATE': 'phaserRate', 'PHASER DEPTH': 'phaserDepth', 'PHASER FEEDBACK': 'phaserFeedback', 'PHASER MIX': 'phaserMix', | |
| 'FLANGER RATE': 'flangerRate', 'FLANGER DEPTH': 'flangerDepth', 'FLANGER FEEDBACK': 'flangerFeedback', 'FLANGER MIX': 'flangerMix', | |
| 'QUAZAR RATE': 'quazarRate', 'QUAZAR RESO': 'quazarReso', 'QUAZAR FREQ': 'quazarFreq', 'QUAZAR MIX': 'quazarMix', | |
| 'CHOPPER DEPTH': 'chopperDepth', | |
| 'CRUSHER BITDEPTH': 'crusherBitDepth', 'CRUSHER DOWNSAMPLE': 'crusherFreq', 'CRUSHER MIX': 'crusherMix', | |
| }; | |
| document.querySelectorAll('.row.is-modulated').forEach(row => row.classList.remove('is-modulated')); | |
| for (const dest of modulatedDests) { | |
| const elementId = destToElementIdMap[dest]; | |
| if (elementId) { | |
| const el = document.getElementById(elementId); | |
| if (el) { | |
| el.closest('.row').classList.add('is-modulated'); | |
| } | |
| } | |
| } | |
| } | |
| setupControls() { | |
| const audio = this.audioSystem; | |
| const addListener = (element, event, handler, options = {}) => { | |
| element.addEventListener(event, handler, options); | |
| this.eventHandlers.push({ | |
| element, | |
| event, | |
| handler, | |
| options | |
| }); | |
| }; | |
| const controlsContainer = document.getElementById('controls'); | |
| addListener(controlsContainer, 'dblclick', (e) => { | |
| if (e.target.tagName === 'INPUT' && e.target.type === 'range') { | |
| const controlId = e.target.id; | |
| const presetState = this.audioSystem.lastLoadedPresetState; | |
| if (presetState && presetState.controls) { | |
| const presetControl = presetState.controls.find(c => c.id === controlId); | |
| if (presetControl) { | |
| e.target.value = presetControl.value; | |
| e.target.dispatchEvent(new Event('input', { bubbles: true })); | |
| } | |
| } | |
| else if (e.target.dataset.default) { | |
| e.target.value = e.target.dataset.default; | |
| e.target.dispatchEvent(new Event('input', { bubbles: true })); | |
| } | |
| } | |
| }); | |
| addListener(controlsContainer, 'input', (e) => { | |
| if (e.target.type === 'range') { | |
| const readout = e.target.nextElementSibling; | |
| if (readout && readout.classList.contains('param-value')) { | |
| let value = parseFloat(e.target.value); | |
| const step = parseFloat(e.target.step) || 1; | |
| const decimals = step < 1 ? (step.toString().split('.')[1] || '').length : 0; | |
| readout.textContent = value.toFixed(decimals); | |
| } | |
| } | |
| }); | |
| 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); | |
| }); | |
| addListener(document.getElementById("generateFromSeedButton"), "click", () => { | |
| const seedInput = document.getElementById("seedInput"); | |
| let seed = seedInput.value.trim(); | |
| if (!seed) { | |
| seed = Date.now().toString(36) + Math.random().toString(36).substring(2); | |
| seedInput.value = seed; | |
| } | |
| audio.generateFromSeed(seed); | |
| this.syncUiToAudioState(); | |
| }); | |
| addListener(document.getElementById("randomSeedButton"), "click", () => { | |
| const seedInput = document.getElementById("seedInput"); | |
| seedInput.value = Date.now().toString(36) + Math.random().toString(36).substring(2); | |
| }); | |
| 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)); | |
| const setVolume = (gainNode, value) => { | |
| 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))); | |
| addListener(document.getElementById('masterVolume'), 'input', e => setVolume(audio.masterGain, parseFloat(e.target.value))); | |
| document.querySelectorAll('.mute-btn, .solo-btn').forEach(btn => { | |
| addListener(btn, 'click', () => { | |
| const track = btn.dataset.track; | |
| const isSolo = btn.classList.contains('solo-btn'); | |
| if (isSolo) { | |
| audio.trackState[track].soloed = !audio.trackState[track].soloed; | |
| if (audio.trackState[track].soloed) { | |
| audio.trackState[track].muted = false; | |
| document.querySelector(`.mute-btn[data-track="${track}"]`).classList.remove('active'); | |
| } | |
| } else { | |
| audio.trackState[track].muted = !audio.trackState[track].muted; | |
| } | |
| for (const t in audio.trackState) { | |
| document.querySelector(`.mute-btn[data-track="${t}"]`).classList.toggle('active', audio.trackState[t].muted); | |
| document.querySelector(`.solo-btn[data-track="${t}"]`).classList.toggle('active', audio.trackState[t].soloed); | |
| } | |
| audio.updateTrackGains(); | |
| }); | |
| }); | |
| document.querySelectorAll('.track-length').forEach(input => { | |
| addListener(input, 'change', (e) => { | |
| const track = e.target.dataset.track; | |
| let len = parseInt(e.target.value, 10); | |
| if (isNaN(len) || len < 1) len = 1; | |
| if (len > 32) len = 32; | |
| e.target.value = len; | |
| audio.sequenceLengths[track] = len; | |
| audio.updateVisibleStepsUI(); | |
| }); | |
| }); | |
| 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); | |
| this.updateModulationHighlights(); | |
| }); | |
| 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', () => { | |
| const text = document.getElementById('importTextarea').value.trim(); | |
| if (!text) { | |
| alert('Text area is empty!'); | |
| return; | |
| } | |
| try { | |
| 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) { | |
| if (data.patches && Array.isArray(data.patches)) { | |
| data.patches.forEach(p => p.id = crypto.randomUUID()); | |
| } | |
| audio.setState(data); | |
| alert('Track loaded successfully!'); | |
| } else { | |
| 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'; | |
| this.updateModulationHighlights(); | |
| } catch (e) { | |
| console.error("Import error:", e); | |
| alert(`Failed to load. Please check the code.\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'; | |
| 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, | |
| 'KeyW': 1, 'KeyE': 1, 'KeyT': 1, 'KeyY': 1, 'KeyU': 1, 'KeyO': 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; | |
| audio.addPatch(sS.value, dS.value, amt); | |
| this.updateModulationHighlights(); | |
| }); | |
| addListener(document.getElementById('activePatches'), 'click', (e) => { | |
| const patchInstance = e.target.closest('.patch-instance'); | |
| if (!patchInstance) return; | |
| if (e.target.tagName === 'BUTTON' && e.target.textContent === 'X') { | |
| if (audio.removePatch(patchInstance.dataset.patchId)) { | |
| audio.renderPatchesUI(); | |
| this.updateModulationHighlights(); | |
| } | |
| } else { | |
| audio.openPatchEditor(patchInstance.dataset.patchId); | |
| } | |
| }); | |
| 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'; | |
| 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; | |
| 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); | |
| }); | |
| 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; | |
| if (audio.isMuted) { | |
| audio.resetAllModulatedParamsToRaw(); | |
| } | |
| muteButton.textContent = audio.isMuted ? "UNMUTE" : "MUTE MODS"; | |
| muteButton.classList.toggle('active', audio.isMuted); | |
| }); | |
| 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]]; | |
| } | |
| audio.renderPatchesUI(); | |
| this.updateModulationHighlights(); | |
| }); | |
| globalControlsContainer.appendChild(buttonRow); | |
| 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" data-default="1"><span class="param-value" style="min-width: 40px;">1.00</span>`; | |
| globalControlsContainer.appendChild(gmDiv); | |
| const gms = gmDiv.querySelector("#globalModScale"); | |
| addListener(gms, "input", e => { | |
| audio.globalModScale = parseFloat(e.target.value); | |
| }); | |
| 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 < 32; 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(); | |
| this.syncUiToAudioState(); | |
| }); | |
| 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(); | |
| const stepEl = e.target.closest('.lyra-step'); | |
| if (!stepEl) return; | |
| const stepIndex = parseInt(stepEl.dataset.step, 10); | |
| if (isNaN(stepIndex)) return; | |
| openSynthStepEditor(audio, stepIndex); | |
| }); | |
| } | |
| } | |
| window.app = new App(); | |
| </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