Skip to content

Instantly share code, notes, and snippets.

@semanticentity
Created March 21, 2026 10:08
Show Gist options
  • Select an option

  • Save semanticentity/95ffe53cc92cb45980d6b89c1b9856f5 to your computer and use it in GitHub Desktop.

Select an option

Save semanticentity/95ffe53cc92cb45980d6b89c1b9856f5 to your computer and use it in GitHub Desktop.
2GETHER SEQUENCER 💕

2GETHER SEQUENCER 💕

Single-file HTML game using Canvas 2D for rendering and Web Audio API for procedural music/SFX

  • Gameplay: Two-player movement with a distance-based “love meter”; collect items, avoid hazards
  • Music system: Step sequencer with bar-level generative variation (phrase locking, pruning, occasional backbeat) and controlled melodic evolution (density normalization, major/minor mode)
  • Extras: Background “garden” canvas that fills with flowers during play; optional in-browser recording via MediaRecorder
  • Cheat Code: Enter the Konami code (Up Up Down Down Left Right Left Right B A) while paused or pre-game to unlock continuous background flower blooming and a special button pulse/jingle for that session

A Pen by SemanticEntity on CodePen.

License.

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>2gether</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=DotGothic16&family=IBM+Plex+Mono:wght@400;500&family=Pixelify+Sans:wght@400..700&display=swap"
rel="stylesheet">
<style>
body {
margin: 0;
padding: 0;
background: radial-gradient(circle at center, #2b2b2b 0%, #1b1b1b 45%, #0f0f0f 100%);
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
font-family: 'Pixelify Sans', sans-serif;
/* overflow: hidden; */
min-width: 320px;
}
@supports (-webkit-touch-callout: none) {
* {
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-rendering: optimizeLegibility;
}
.scanlines,
.scanlines-light {
text-shadow: 0 0 1px currentColor;
}
}
#gardenCanvas {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
z-index: 0;
image-rendering: pixelated;
border: none;
outline: none;
display: block;
background: transparent;
}
/* Scanlines overlay for the entire background */
#gardenScanlines {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
z-index: 0;
pointer-events: none;
background: linear-gradient(rgba(18, 16, 16, 0) 50%, rgb(0 0 0 / 50%) 50%), linear-gradient(90deg, rgba(255, 0, 0, 0.02), rgba(0, 255, 0, 0.015), rgba(0, 0, 255, 0.02));
background-size: 100% 2px, 3px 100%;
}
h1 {
font-family: 'DotGothic16', monospace;
font-size: clamp(54px, 18vw, 100px);
font-weight: bold;
margin: 0;
text-shadow: 2px 2px 0 #000;
}
#title {
color: #F0F;
line-height: 1;
}
#title .title-letter {
display: inline-block;
will-change: transform;
}
#gameContainer {
text-align: center;
background: rgb(3 54 160 / 80%);
padding: 20px 4px;
border-radius: 10px;
box-shadow: 2px 2px 180px #000000aa;
position: relative;
z-index: 1;
max-width: 94vw;
margin: 10px 0;
box-sizing: border-box;
}
#gameContainer::after {
content: "";
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
pointer-events: none;
background: linear-gradient(rgba(18, 16, 16, 0) 50%, rgb(0 0 0 / 50%) 50%), linear-gradient(90deg, rgba(255, 0, 0, 0.02), rgba(0, 255, 0, 0.015), rgba(0, 0, 255, 0.02));
background-size: 100% 2px, 3px 100%;
border-radius: 10px;
z-index: 100;
}
/* Global scanline mixin for modals and panels */
.scanlines::after {
content: "";
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
pointer-events: none;
background: linear-gradient(rgba(18, 16, 16, 0) 50%, rgb(0 0 0 / 50%) 50%), linear-gradient(90deg, rgba(255, 0, 0, 0.02), rgba(0, 255, 0, 0.015), rgba(0, 0, 255, 0.02));
background-size: 100% 2px, 3px 100%;
border-radius: inherit;
z-index: 100;
}
/* Lighter scanlines for readability */
.scanlines-light::after {
content: "";
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
pointer-events: none;
background: linear-gradient(rgba(18, 16, 16, 0) 50%, rgb(0 0 0 / 35%) 50%), linear-gradient(90deg, rgba(255, 0, 0, 0.015), rgba(0, 255, 0, 0.01), rgba(0, 0, 255, 0.015));
background-size: 100% 2px, 3px 100%;
border-radius: inherit;
z-index: 100;
}
#gameCanvas {
border: 4px solid #FFF;
image-rendering: pixelated;
cursor: pointer;
border-radius: 10px;
margin-bottom: 0;
touch-action: none;
width: min(92vw, 400px);
height: auto;
box-sizing: border-box;
display: block;
margin-left: auto;
margin-right: auto;
}
#score {
color: #FFF;
font-size: clamp(14px, 4.5vw, 17px);
margin: 0;
text-shadow: 2px 2px 0 #000;
white-space: nowrap;
}
#message {
color: #F0F;
font-size: 18px;
margin: -5px 0 5px;
height: 25px;
padding: 0 0 10px;
text-shadow: 1px 1px 0 #000;
}
button {
background: rgb(255 0 255 / 60%);
color: #FFF;
border: none;
padding: 10px 20px;
font-size: 24px;
text-shadow: 1px 1px 0 #000;
box-shadow: 2px 2px 0 #000;
cursor: pointer;
border-radius: 5px;
font-family: "DotGothic16", monospace;
line-height: 1.5;
margin: 5px;
}
button:hover {
background: rgb(255 255 0 / 85%);
color: #F0F;
}
button#gameBtn {
width: 50%;
}
button#recBtn,
button#musicBtn,
button#sfxBtn {
font-size: 18px;
padding: 4px 8px;
}
button.xwave-btn {
margin: 1px;
}
@keyframes recPulse {
0%,
100% {
background: #F00;
box-shadow: 2px 2px 0 #000;
}
50% {
background: #FF0;
box-shadow: 2px 2px 18px #FF0;
}
}
#recBtn.pulsing {
animation: recPulse 0.9s ease-in-out infinite;
}
#instructions {
color: #0FF;
margin: 0;
padding: 0;
font-size: 18px;
text-shadow: 1px 1px 0 #000;
}
#instructions p {
margin: 0;
}
@media (max-width: 420px) {
#gameContainer {
padding: 12px 0;
border-radius: 8px;
}
h1 {
margin-bottom: -6px;
}
#gameCanvas {
border-width: 3px;
border-radius: 8px;
margin-top: 2px;
width: min(92vw, 310px);
}
#score {
margin-top: 2px;
margin-bottom: 2px;
}
#message {
margin: -2px 0 8px;
height: 18px;
padding: 0;
}
#instructions {
font-size: 13px;
}
button {
font-size: 18px;
padding: 8px 14px;
}
}
#pauseOverlay {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.7);
display: none;
justify-content: center;
align-items: center;
flex-direction: column;
z-index: 50;
border-radius: 8px;
}
#pauseOverlay span {
color: #FF0;
font-size: 48px;
text-shadow: 3px 3px 0 #000;
text-align: center;
display: block;
}
#nameEntryOverlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.85);
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
display: none;
flex-direction: column;
justify-content: center;
align-items: center;
z-index: 60;
padding: 20px;
box-sizing: border-box;
}
#nameEntryOverlay h2 {
color: #F0F;
font-size: 24px;
margin-bottom: 10px;
text-shadow: 2px 2px 0 #000;
}
#nameEntryOverlay .final-score {
color: #0FF;
font-size: 18px;
margin-bottom: 15px;
}
.name-display {
background: #000;
border: 3px solid #F0F;
padding: 10px 20px;
font-size: 28px;
color: #0FF;
letter-spacing: 4px;
margin-bottom: 15px;
min-width: 200px;
text-align: center;
min-height: 40px;
}
.char-picker {
display: grid;
grid-template-columns: repeat(6, 32px);
justify-content: center;
gap: 4px;
max-width: calc(6 * 32px + 5 * 4px);
margin-bottom: 10px;
}
.char-btn {
width: 32px;
height: 32px;
background: #333;
border: 2px solid #666;
color: #FFF;
font-size: 16px;
cursor: pointer;
font-family: inherit;
display: flex;
align-items: center;
justify-content: center;
box-sizing: border-box;
padding: 0;
margin: 0;
border-radius: 4px;
line-height: 1;
}
.char-btn:hover {
background: #F0F;
border-color: #F0F;
color: #FFF;
}
.char-btn.special {
width: calc(50% - 2px);
font-size: 14px;
height: 36px;
}
.emoji-row {
display: flex;
gap: 4px;
margin-bottom: 10px;
margin-top: 10px;
}
.emoji-btn {
width: 36px;
height: 36px;
background: #222;
border: 2px solid #F0F;
font-size: 20px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
padding: 0;
margin: 0;
box-sizing: border-box;
line-height: 1;
}
.emoji-btn:hover {
background: #F0F;
color: #FFF;
}
.special-row {
display: flex;
gap: 4px;
width: 100%;
max-width: 260px;
}
#leaderboard {
background: #111;
border: 2px solid #0FF;
padding: 10px;
margin-top: 10px;
max-height: 150px;
overflow-y: auto;
width: 100%;
max-width: 300px;
}
#leaderboard h3 {
color: #0FF;
font-size: 14px;
margin: 0 0 8px 0;
text-align: center;
}
.lb-entry {
color: #FFF;
font-size: 12px;
padding: 2px 0;
border-bottom: 1px solid #333;
}
.lb-entry.current {
color: #F0F;
font-weight: bold;
}
.shake {
animation: shake 0.1s ease-in-out;
}
.canvas-wrap {
position: relative;
display: inline-block;
}
.btn-row {
display: flex;
gap: 8px;
justify-content: center;
}
.mb8 {
margin-bottom: 8px;
}
.mt15 {
margin-top: 15px;
}
.btn-mt10 {
margin-top: 10px;
}
.btn-mt5 {
margin-top: 5px;
}
.bg-gray-666 {
background: #666;
}
.is-hidden {
display: none;
}
#leaderboardOverlay {
display: none;
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.85);
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
z-index: 100;
flex-direction: column;
justify-content: center;
align-items: center;
}
#instructions p {
color: #FFF;
}
.c-magenta {
color: #F0F;
}
.c-cyan {
color: cyan;
}
.lb-entry-row {
display: grid;
grid-template-columns: 1fr auto auto;
column-gap: 8px;
align-items: center;
}
.lb-entry-name {
flex: 1;
}
.lb-entry-meta {
white-space: nowrap;
font-variant-numeric: tabular-nums;
}
.lb-entry-play {
padding: 6px 10px;
font-size: 14px;
margin: 0;
}
.lb-empty {
color: #666;
text-align: center;
}
@keyframes shake {
0%,
100% {
transform: translateX(0);
}
25% {
transform: translateX(-3px);
}
75% {
transform: translateX(3px);
}
}
/* Drift button animation */
@keyframes driftPulse {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.6;
}
}
@keyframes blink {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.3;
}
}
@keyframes chapterPulse {
0%,
100% {
transform: scale(1);
box-shadow: 0 0 4px #0F0;
}
50% {
transform: scale(1.1);
box-shadow: 0 0 12px #0F0;
}
}
.drift-active {
animation: driftPulse 0.5s ease-in-out infinite;
}
/* Slider styling - hot pink/magenta */
#sequencerModal input[type="range"],
#voiceConfigModal input[type="range"] {
-webkit-appearance: none;
appearance: none;
background: linear-gradient(to right, #3a1a3a 0%, #602060 100%);
border-radius: 4px;
height: 6px;
}
#sequencerModal input[type="range"]::-webkit-slider-thumb,
#voiceConfigModal input[type="range"]::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 16px;
height: 16px;
background: #F0F;
border-radius: 50%;
cursor: pointer;
box-shadow: 0 0 8px rgba(255, 0, 255, 0.6);
}
#sequencerModal input[type="range"]::-moz-range-thumb,
#voiceConfigModal input[type="range"]::-moz-range-thumb {
width: 16px;
height: 16px;
background: #F0F;
border-radius: 50%;
cursor: pointer;
border: none;
box-shadow: 0 0 8px rgba(255, 0, 255, 0.6);
}
#sequencerModal input[type="range"]::-webkit-slider-runnable-track,
#voiceConfigModal input[type="range"]::-webkit-slider-runnable-track {
background: linear-gradient(to right, #3a1a3a 0%, #602060 100%);
border-radius: 4px;
height: 6px;
}
/* Sequencer mobile responsive */
@media (max-width: 768px) {
#sequencerModal {
padding: 10px !important;
margin: 10px !important;
max-width: calc(100vw - 20px) !important;
}
#seqDriftControls {
grid-template-columns: repeat(3, 1fr) !important;
}
#seqInfoRow1 {
grid-template-columns: auto auto auto 1fr !important;
}
#seqInfoRow2 {
grid-template-columns: repeat(5, 1fr) !important;
}
}
@media (max-width: 500px) {
#sequencerOverlay {
align-items: flex-start !important;
padding: 0 !important;
}
#sequencerModal {
padding: 8px !important;
margin: 0 !important;
max-height: 100vh !important;
max-width: 100vw !important;
width: 100vw !important;
overflow-y: auto !important;
border-left: none !important;
border-right: none !important;
}
#seqDriftControls {
grid-template-columns: repeat(3, 1fr) !important;
gap: 3px !important;
}
#seqInfoRow1 {
grid-template-columns: repeat(2, 1fr) !important;
}
#seqInfoRow2 {
grid-template-columns: repeat(3, 1fr) !important;
}
#seqInfoRow2>div:nth-child(4),
#seqInfoRow2>div:nth-child(5) {
grid-column: span 1 !important;
}
#snapshotList {
flex-wrap: wrap !important;
gap: 4px !important;
}
}
@media (max-width: 380px) {
#seqBottomRow {
flex-direction: column !important;
}
#seqBottomRow>div {
width: 100% !important;
min-width: 100% !important;
}
#seqActionBtns {
width: 100% !important;
}
#seqActionBtns button {
flex: 1 !important;
}
}
/* Music Settings Modal */
#musicSettingsOverlay {
display: none;
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.9);
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
z-index: 150;
flex-direction: column;
justify-content: center;
align-items: center;
}
#musicSettingsModal {
background: #111;
border: 2px solid #F0F;
padding: 12px 16px;
max-width: 380px;
width: 90%;
max-height: 85vh;
overflow-y: auto;
}
#musicSettingsModal h3 {
color: #F0F;
font-size: 14px;
margin: 0 0 8px 0;
text-align: center;
}
.music-setting-row {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 4px;
padding: 4px 6px;
background: #1a1a1a;
border-radius: 3px;
gap: 8px;
}
.music-setting-row label {
color: #888;
font-size: 10px;
min-width: 70px;
}
.music-setting-row input[type="number"] {
width: 44px;
padding: 3px;
background: #000;
border: 1px solid #444;
color: #FFF;
font-family: inherit;
font-size: 11px;
text-align: center;
}
.music-setting-row input[type="range"] {
flex: 1;
height: 4px;
accent-color: #F0F;
}
.music-setting-row .setting-value {
color: #F0F;
font-size: 10px;
min-width: 28px;
text-align: right;
}
.music-setting-section {
color: #555;
font-size: 9px;
text-transform: uppercase;
letter-spacing: 1px;
margin: 8px 0 4px 0;
border-bottom: 1px solid #222;
padding-bottom: 4px;
}
#musicSettingsModal .btn-row {
margin-top: 15px;
}
</style>
</head>
<body>
<canvas id="gardenCanvas"></canvas>
<div id="gardenScanlines"></div>
<div id="gameContainer">
<h1 id="title">2GETHER</h1>
<div id="score">Level: 1 | Score: 0 | Love Meter: ❤️❤️❤️</div>
<div id="message"></div>
<div class="canvas-wrap">
<canvas id="gameCanvas" width="400" height="400"></canvas>
<div id="pauseOverlay"><span id="pauseText">⏸ PAUSED</span>
<div style="font-size:10px;margin-top:8px;opacity:0.7;text-align:center;">tap to resume · drag players to move
</div>
</div>
<div id="nameEntryOverlay">
<h2>ONE MORE CHANCE?</h2>
<div class="final-score">Score: <span id="finalScore">0</span> | Level: <span id="finalLevel">1</span></div>
<div class="name-display" id="nameDisplay">_</div>
<div class="char-picker" id="charPicker"></div>
<div class="emoji-row" id="emojiRow"></div>
<div class="special-row" id="specialRow"></div>
<button onclick="submitScore()" class="btn-mt10">Save Score</button>
<button onclick="skipScore()" class="btn-mt5 bg-gray-666">Skip</button>
</div>
</div>
<!-- Leaderboard overlay (shown after submit or via button) -->
<div id="leaderboardOverlay">
<div id="leaderboard">
<h3>🏆 HIGH SCORES</h3>
<div id="leaderboardList"></div>
</div>
<div class="btn-row mt15">
<button onclick="startNewRunFromLeaderboard()">New Game</button>
<button onclick="closeLeaderboard()">Close</button>
</div>
</div>
<!-- Music Settings Modal -->
<div id="musicSettingsOverlay" class="scanlines">
<div id="musicSettingsModal" style="position:relative;">
<button onclick="closeMusicSettings()"
style="position:absolute;top:8px;right:8px;background:none;border:none;color:#666;font-size:18px;cursor:pointer;padding:4px 8px;"></button>
<h3>🎵 Music Settings</h3>
<div class="music-setting-section">Tempo</div>
<div class="music-setting-row">
<label title="Min/max tempo range for auto-drift">BPM Range</label>
<input type="number" id="musicBpmMin" value="83" min="60" max="180"
onchange="updateMusicSettings(); updateBpmSliderRange();" style="width:50px;">
<span style="color:#666;margin:0 4px"></span>
<input type="number" id="musicBpmMax" value="124" min="60" max="180"
onchange="updateMusicSettings(); updateBpmSliderRange();" style="width:50px;">
</div>
<div class="music-setting-row">
<label title="Current tempo (adjusts live)">Current BPM</label>
<input type="range" id="musicBpmSlider" min="83" max="124" value="92" oninput="setBpmFromSlider()">
<span class="setting-value" id="musicBpmSliderVal">92</span>
</div>
<div class="music-setting-row">
<label title="Overall volume level">Master Gain</label>
<input type="range" id="musicMasterGain" min="0" max="200" value="100" oninput="setMasterGain(this.value)">
<span class="setting-value" id="musicMasterGainVal">100%</span>
</div>
<div class="music-setting-section">Generation</div>
<div class="music-setting-row">
<label title="Lock to a specific kit pattern">Force Kit</label>
<select id="musicKitSelect" onchange="updateKitSelection()"
style="flex:1;padding:4px;background:#000;border:1px solid #444;color:#FFF;font-family:inherit;font-size:10px;">
<option value="">(Random)</option>
</select>
</div>
<div class="music-setting-row">
<label title="% chance to use kit vs random patterns">Kit Chance</label>
<input type="range" id="musicKitChance" min="0" max="100" value="51" oninput="updateMusicSettings()">
<span class="setting-value" id="musicKitChanceVal">51%</span>
</div>
<div class="music-setting-row">
<label title="% chance for energy boost sections">Chorus Chance</label>
<input type="range" id="musicChorusChance" min="0" max="50" value="28" oninput="updateMusicSettings()">
<span class="setting-value" id="musicChorusChanceVal">28%</span>
</div>
<div class="music-setting-row">
<label title="Game hits needed to trigger rally mode">Rally Threshold</label>
<input type="range" id="musicRallyThreshold" min="5" max="20" value="13" oninput="updateMusicSettings()">
<span class="setting-value" id="musicRallyThresholdVal">13</span>
</div>
<div class="music-setting-row">
<label title="How long melodic phrases last">Motif Bars</label>
<select id="musicMotifBars" onchange="updateMusicSettings()"
style="padding:4px;background:#000;border:1px solid #444;color:#FFF;font-family:inherit;font-size:10px;">
<option value="1">1 bar</option>
<option value="2">2 bars</option>
<option value="4" selected>4 bars</option>
<option value="8">8 bars</option>
</select>
</div>
<div class="music-setting-row">
<label title="Which synth plays the melody">Motif Voice</label>
<select id="musicMotifVoice" onchange="updateMotifVoice()"
style="flex:1;padding:4px;background:#000;border:1px solid #444;color:#FFF;font-family:inherit;font-size:10px;">
<option value="">(Random)</option>
<option value="xsidLead">SID Lead</option>
<option value="chipLead">Chip Lead</option>
<option value="marsLead">Mars Lead</option>
<option value="xpoly">Poly</option>
<option value="xwave">GB Wave</option>
<option value="xwaveX">GB Wave×</option>
<option value="xwave3d">GB Wave 3D</option>
<option value="fmWhammy">FM Whammy</option>
<option value="fmPad">FM Pad</option>
<option value="fmBrass">FM Brass</option>
<option value="acid">Acid 303</option>
</select>
</div>
<div class="music-setting-row">
<label title="Musical scale/mood">Scale</label>
<select id="musicScale" onchange="updateMusicSettings()"
style="flex:1;padding:4px;background:#000;border:1px solid #444;color:#FFF;font-family:inherit;font-size:10px;">
<option value="minor" selected>Minor</option>
<option value="major">Major</option>
<option value="dorian">Dorian</option>
<option value="phrygian">Phrygian</option>
<option value="pentatonic">Pentatonic</option>
<option value="mixolydian">Mixolydian</option>
</select>
</div>
<!-- HIDDEN: Tuss Bass not ready yet -->
<div class="music-setting-row" style="display:none;">
<label title="Bass synthesis style">Bass Style</label>
<select id="musicBassStyle" onchange="updateBassStyle()"
style="flex:1;padding:4px;background:#000;border:1px solid #444;color:#FFF;font-family:inherit;font-size:10px;">
<option value="classic">Classic (SID)</option>
<option value="tuss">Tuss (Juicy)</option>
</select>
</div>
<div class="music-setting-section">Transitions</div>
<div class="music-setting-row">
<label title="Force kit change after N bars">Kit Stale Max</label>
<input type="range" id="musicKitStaleMax" min="8" max="64" value="32" oninput="updateTransitionSettings()">
<span class="setting-value" id="musicKitStaleMaxVal">32</span>
</div>
<div class="music-setting-row">
<label title="Bars to spread kit transition over">Transition Bars</label>
<input type="range" id="musicTransitionBars" min="2" max="10" value="6" oninput="updateTransitionSettings()">
<span class="setting-value" id="musicTransitionBarsVal">6-8</span>
</div>
<div class="music-setting-row">
<label title="% step-morph vs instant swap (drums/bass always morph)">Morph Chance</label>
<input type="range" id="musicMorphChance" min="0" max="80" value="10" oninput="updateTransitionSettings()">
<span class="setting-value" id="musicMorphChanceVal">10%</span>
</div>
<!-- Advanced Section (collapsible) -->
<div class="music-setting-section" style="cursor:pointer;user-select:none;" onclick="toggleAdvancedMusic()">
<span id="advancedMusicToggle"></span> Advanced
</div>
<div id="advancedMusicPanel" style="display:none;margin-bottom:8px;">
<!-- Voice Mutes -->
<div style="margin-bottom:8px;padding:6px;background:#0a0a0a;border:1px solid #333;border-radius:4px;">
<div style="color:#888;font-size:9px;margin-bottom:4px;">VOICE MUTES — Drums</div>
<div style="display:flex;flex-wrap:wrap;gap:4px;margin-bottom:6px;">
<label style="color:#F44;font-size:9px;cursor:pointer;"><input type="checkbox" id="gameMuteKick"
onchange="updateGameVoiceMutes()" style="margin-right:2px;">Kick</label>
<label style="color:#FA4;font-size:9px;cursor:pointer;"><input type="checkbox" id="gameMuteSnare"
onchange="updateGameVoiceMutes()" style="margin-right:2px;">Snare</label>
<label style="color:#FF4;font-size:9px;cursor:pointer;"><input type="checkbox" id="gameMuteHihat"
onchange="updateGameVoiceMutes()" style="margin-right:2px;">Hihat</label>
</div>
<div style="color:#888;font-size:9px;margin-bottom:4px;">VOICE MUTES — Melodic</div>
<div style="display:flex;flex-wrap:wrap;gap:4px;margin-bottom:6px;">
<label style="color:#4F4;font-size:9px;cursor:pointer;"><input type="checkbox" id="gameMuteBass"
onchange="updateGameVoiceMutes()" style="margin-right:2px;">Bass</label>
<label style="color:#4FF;font-size:9px;cursor:pointer;"><input type="checkbox" id="gameMutePad"
onchange="updateGameVoiceMutes()" style="margin-right:2px;">Pad</label>
</div>
<div style="color:#888;font-size:9px;margin-bottom:4px;">VOICE MUTES — Leads</div>
<div style="display:flex;flex-wrap:wrap;gap:4px;">
<label style="color:#9BBC0F;font-size:9px;cursor:pointer;"><input type="checkbox" id="gameMuteSidLead"
onchange="updateGameVoiceMutes()" style="margin-right:2px;">SID</label>
<label style="color:#70A4B2;font-size:9px;cursor:pointer;"><input type="checkbox" id="gameMuteChipLead"
onchange="updateGameVoiceMutes()" style="margin-right:2px;">Chip</label>
<label style="color:#F0F;font-size:9px;cursor:pointer;"><input type="checkbox" id="gameMuteXpoly"
onchange="updateGameVoiceMutes()" style="margin-right:2px;">XPoly</label>
<label style="color:#8BC;font-size:9px;cursor:pointer;"><input type="checkbox" id="gameMuteXwave"
onchange="updateGameVoiceMutes()" style="margin-right:2px;">XWave</label>
<label style="color:#FA0;font-size:9px;cursor:pointer;"><input type="checkbox" id="gameMuteFmWhammy"
onchange="updateGameVoiceMutes()" style="margin-right:2px;">Whammy</label>
<label style="color:#F80;font-size:9px;cursor:pointer;"><input type="checkbox" id="gameMuteFmBrass"
onchange="updateGameVoiceMutes()" style="margin-right:2px;">Brass</label>
<label style="color:#08F;font-size:9px;cursor:pointer;"><input type="checkbox" id="gameMuteFmPad"
onchange="updateGameVoiceMutes()" style="margin-right:2px;">FM Pad</label>
</div>
</div>
<!-- Extended Voices Section (collapsible) -->
<div style="margin-bottom:8px;padding:6px;background:#0a0a0a;border:1px solid #333;border-radius:4px;">
<div class="music-setting-section" style="cursor:pointer;user-select:none;margin-bottom:4px;" onclick="toggleExtendedVoices()">
<span id="extendedVoicesToggle"></span> Extended Voices
</div>
<div id="extendedVoicesPanel" style="display:none;">
<div class="music-setting-row">
<label title="% chance Acid 303 appears during kit transitions">Acid 303</label>
<input type="range" id="musicAcidPrevalence" min="0" max="100" value="35" oninput="updateMusicSettings()">
<span class="setting-value" id="musicAcidPrevalenceVal">35%</span>
</div>
<div class="music-setting-row">
<label title="% chance Drum Pop appears during kit transitions">Drum Pop</label>
<input type="range" id="musicPopPrevalence" min="0" max="100" value="25" oninput="updateMusicSettings()">
<span class="setting-value" id="musicPopPrevalenceVal">25%</span>
</div>
<div class="music-setting-row">
<label title="% chance FM Brass Stab appears during kit transitions">FM Stab</label>
<input type="range" id="musicStabPrevalence" min="0" max="100" value="30" oninput="updateMusicSettings()">
<span class="setting-value" id="musicStabPrevalenceVal">30%</span>
</div>
<div class="music-setting-row">
<label title="% chance FM Whammy appears during kit transitions">FM Whammy</label>
<input type="range" id="musicWhammyPrevalence" min="0" max="100" value="28" oninput="updateMusicSettings()">
<span class="setting-value" id="musicWhammyPrevalenceVal">28%</span>
</div>
<div class="music-setting-row">
<label title="% chance XPoly Lead appears during kit transitions">XPoly Lead</label>
<input type="range" id="musicXpolyPrevalence" min="0" max="100" value="30" oninput="updateMusicSettings()">
<span class="setting-value" id="musicXpolyPrevalenceVal">30%</span>
</div>
<div class="music-setting-row">
<label title="% chance Squarepusher Bass appears during kit transitions">Squarepusher Bass</label>
<input type="range" id="musicSquarepusherPrevalence" min="0" max="100" value="25" oninput="updateMusicSettings()">
<span class="setting-value" id="musicSquarepusherPrevalenceVal">25%</span>
</div>
<div class="music-setting-row">
<label title="% chance µ-Ziq Melody appears during kit transitions">µ-Ziq Melody</label>
<input type="range" id="musicUziqPrevalence" min="0" max="100" value="20" oninput="updateMusicSettings()">
<span class="setting-value" id="musicUziqPrevalenceVal">20%</span>
</div>
</div>
</div>
<!-- Breakdown/Build DJ System -->
<div style="margin-bottom:8px;padding:6px;background:#0a0a0a;border:1px solid #333;border-radius:4px;">
<div style="color:#0FF;font-size:9px;margin-bottom:4px;">🎛️ BREAKDOWN/BUILD</div>
<div class="music-setting-row" style="margin-bottom:4px;">
<label style="font-size:9px;" title="% chance to trigger breakdown when patterns get stale">Breakdown Chance</label>
<input type="range" id="musicBreakdownChance" min="0" max="50" value="20" oninput="updateMusicSettings()">
<span class="setting-value" id="musicBreakdownChanceVal" style="font-size:9px;">20%</span>
</div>
<div class="music-setting-row">
<label style="font-size:9px;" title="Intensity of breakdown effects (0.5 = subtle, 1.0 = full club)">Breakdown Intensity</label>
<input type="range" id="musicBreakdownIntensity" min="50" max="200" value="100" oninput="updateMusicSettings()">
<span class="setting-value" id="musicBreakdownIntensityVal" style="font-size:9px;">100%</span>
</div>
</div>
<!-- Drum Mind Settings -->
<div style="margin-bottom:8px;padding:6px;background:#0a0a0a;border:1px solid #333;border-radius:4px;">
<div style="color:#F44;font-size:9px;margin-bottom:4px;">🥁 DRUM MIND</div>
<div class="music-setting-row" style="margin-bottom:4px;">
<label style="font-size:9px;" title="Probability of ghost/fill notes">Ghost %</label>
<input type="range" id="gameGhostProb" min="0" max="100" value="40" oninput="updateDrumMindSettings()">
<span class="setting-value" id="gameGhostProbVal" style="font-size:9px;">40%</span>
</div>
<div class="music-setting-row">
<label style="font-size:9px;" title="How fast patterns evolve per bar">Evolve Rate</label>
<input type="range" id="gameEvolveRate" min="0" max="100" value="30" oninput="updateDrumMindSettings()">
<span class="setting-value" id="gameEvolveRateVal" style="font-size:9px;">30%</span>
</div>
</div>
<!-- Emergent Melody Settings -->
<div style="margin-bottom:8px;padding:6px;background:#0a0a0a;border:1px solid #333;border-radius:4px;">
<div style="color:#0FF;font-size:9px;margin-bottom:4px;">🎵 EMERGENT MELODY</div>
<div class="music-setting-row" style="margin-bottom:4px;">
<label style="font-size:9px;" title="Chance for motif to play each phrase">Motif Chance</label>
<input type="range" id="gameMotifChance" min="0" max="100" value="55"
oninput="updateEmergentMelodySettings()">
<span class="setting-value" id="gameMotifChanceVal" style="font-size:9px;">55%</span>
</div>
<div class="music-setting-row">
<label style="font-size:9px;" title="Lead responds to bass hits">Call/Response</label>
<input type="range" id="gameCallResponse" min="0" max="100" value="65"
oninput="updateEmergentMelodySettings()">
<span class="setting-value" id="gameCallResponseVal" style="font-size:9px;">65%</span>
</div>
</div>
<!-- Kit Pool -->
<div style="margin-bottom:8px;padding:6px;background:#0a0a0a;border:1px solid #333;border-radius:4px;">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:4px;">
<div style="color:#0F0;font-size:9px;">🎮 KIT POOL</div>
<div style="display:flex;gap:2px;">
<button onclick="selectAllKits(true)"
style="padding:2px 4px;background:#222;border:1px solid #0F0;color:#0F0;font-size:7px;cursor:pointer;">All</button>
<button onclick="selectAllKits(false)"
style="padding:2px 4px;background:#222;border:1px solid #F44;color:#F44;font-size:7px;cursor:pointer;">None</button>
</div>
</div>
<div id="kitPoolCheckboxes"
style="display:flex;flex-wrap:wrap;gap:2px;max-height:80px;overflow-y:auto;font-size:8px;">
<!-- Populated by JS -->
</div>
</div>
<!-- Kit Preview -->
<div style="padding:6px;background:#111;border:1px solid #FA0;border-radius:4px;">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:4px;">
<div style="color:#FA0;font-size:9px;">👁 KIT PREVIEW</div>
<div style="display:flex;gap:4px;align-items:center;">
<button id="kitPreviewBpmLock" onclick="togglePreviewBpmLock()"
title="Lock BPM (use current instead of kit's)"
style="padding:2px 4px;background:#222;border:1px solid #666;color:#666;font-size:8px;cursor:pointer;">🔓</button>
<button id="kitPreviewApplySettings" onclick="togglePreviewApplySettings()"
title="Apply music settings (ghost, motif, etc)"
style="padding:2px 4px;background:#222;border:1px solid #666;color:#666;font-size:8px;cursor:pointer;">⚙️
Dry</button>
</div>
</div>
<div id="winamp-player"
style="border:1px solid #444; background:#1a1a1a; padding:4px; font-family: monospace; display: flex; flex-direction: column; gap:4px; margin-top:2px;">
<!-- Display -->
<div
style="background:#000; border:1px solid #333; height: 16px; display:flex; align-items:center; padding:0 4px;">
<div id="winamp-display"
style="color:#0f0; font-size:9px; white-space:nowrap; overflow:hidden; width:100%;">LOADING...</div>
<div id="winamp-kbps" style="color:#0f0; font-size:7px; margin-left:4px;">192</div>
</div>
<!-- Controls -->
<div style="display:flex; justify-content:space-between; align-items:center;">
<div style="display:flex; gap:1px;">
<button onclick="winampPrev()" title="Previous Kit"
style="background:linear-gradient(#444,#222); border:1px solid #000; color:#ccc; font-size:8px; width:16px; height:12px; padding:0; cursor:pointer;">&lt;</button>
<button onclick="toggleKitPlay()" id="winamp-play-btn" title="Play/Stop"
style="background:linear-gradient(#444,#222); border:1px solid #000; color:#0f0; font-size:8px; width:16px; height:12px; padding:0; cursor:pointer;"></button>
<button onclick="winampNext()" title="Next Kit"
style="background:linear-gradient(#444,#222); border:1px solid #000; color:#ccc; font-size:8px; width:16px; height:12px; padding:0; cursor:pointer;">&gt;</button>
</div>
<button onclick="winampShuffle()" title="Shuffle Kit"
style="background:linear-gradient(#444,#222); border:1px solid #000; color:#fa0; font-size:7px; padding:0 4px; height:12px; cursor:pointer;">SHUF</button>
<div style="display:flex; gap:1px;">
<!-- Playlist toggle (future) -->
<div style="width:4px; height:4px; background:#444; border-radius:50%; margin-top:4px;"></div>
<div style="width:4px; height:4px; background:#444; border-radius:50%; margin-top:4px;"></div>
<div style="width:4px; height:4px; background:#444; border-radius:50%; margin-top:4px;"></div>
</div>
</div>
</div>
</div>
</div>
<div class="music-setting-section">Log</div>
<div id="musicEvolutionLog"
style="color:#0FF;font-size:10px;font-family:'IBM Plex Mono',monospace;max-height:50px;overflow-y:auto;background:#000;padding:4px;border:1px solid #333;margin-bottom:6px;line-height:1.3;">
</div>
<div style="display:flex;gap:4px;margin-bottom:8px;">
<button id="musicPlayStopBtn" onclick="toggleMusicPlayStop()"
style="flex:2;padding:6px 0;font-size:11px;background:#0F0;color:#000;border:none;cursor:pointer;">
Play</button>
<button onclick="forceRerollMusic()"
style="flex:2;padding:6px 0;font-size:11px;background:#F0F;color:#000;border:none;cursor:pointer;">Reroll</button>
<button id="musicRecBtn" onclick="toggleRecording()"
style="flex:1;padding:6px 0;font-size:10px;background:#F00;color:#FFF;border:none;cursor:pointer;">Rec</button>
<button onclick="rewindToSnapshot()"
style="flex:1;padding:6px 0;font-size:10px;background:#FA0;color:#000;border:none;cursor:pointer;">Back</button>
<button onclick="takeSnapshot()"
style="flex:1;padding:6px 0;font-size:10px;background:#0AF;color:#000;border:none;cursor:pointer;">Save</button>
</div>
<div style="display:flex;gap:4px;">
<button onclick="clearEvolutionLog()"
style="flex:1;padding:3px;font-size:9px;background:#333;border:none;color:#888;cursor:pointer;">Clear</button>
<button onclick="exportSoundtrack()"
style="flex:1;padding:3px;font-size:9px;background:#0AA;color:#000;border:none;cursor:pointer;">Export</button>
<button onclick="document.getElementById('importSoundtrackInput').click()"
style="flex:1;padding:3px;font-size:9px;background:#A0A;color:#000;border:none;cursor:pointer;">Import</button>
<input type="file" id="importSoundtrackInput" accept=".json" style="display:none;"
onchange="importSoundtrack(event)">
<button onclick="openSequencer()"
style="flex:1;padding:3px;font-size:9px;background:#222;border:1px solid #F0F;color:#F0F;cursor:pointer;">🎹
Seq</button>
</div>
</div>
</div>
<!-- Visual Sequencer Modal -->
<div id="sequencerOverlay"
style="display:none;position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,0.98);z-index:200;overflow-y:auto;align-items:center;justify-content:center;min-width:320px;">
<div id="sequencerModal" class="scanlines-light"
style="position:relative;background:#050508;border:2px solid rgb(255 0 255 / 30%);padding:10px;max-width:700px;width:95%;max-height:95vh;overflow-y:auto;min-width:320px;margin:10px 0;">
<div style="display:flex;justify-content:space-between;align-items:center;height: 15px;">
<h3 style="color:#F0F;font-size:12px;margin:0;font-family:'IBM Plex Mono',monospace;">💕 2GETHER SEQUENCER 💕
</h3>
<button onclick="closeSequencer()"
style="background:none;border:none;color:#666;font-size:18px;cursor:pointer;"></button>
</div>
<!-- Transport Controls + Collection Load -->
<div style="display:flex;gap:4px;margin:8px 0;align-items:center;flex-wrap:wrap;">
<button id="seqPlayPauseBtn" onclick="seqTogglePlayPause()"
style="padding:6px 12px;font-size:11px;background:rgb(0 255 0 / 60%);color:#000;border:1px solid #0F0;cursor:pointer;font-family:'IBM Plex Mono',monospace;">
PAUSE</button>
<button id="seqStopBtn" onclick="seqStop()"
style="padding:6px 12px;font-size:11px;background:rgb(255 68 68 / 60%);color:#FFF;border:1px solid #F44;cursor:pointer;font-family:'IBM Plex Mono',monospace;">
STOP</button>
<button id="seqRecBtn" onclick="seqToggleRecording()"
style="padding:6px 12px;font-size:11px;background:#333;color:#FFF;border:1px solid #F00;cursor:pointer;font-family:'IBM Plex Mono',monospace;">
REC</button>
<span id="seqTransportStatus"
style="color:#888;font-size:9px;margin-left:4px;font-family:'IBM Plex Mono',monospace;">STOPPED</span>
<select id="chapterCollectionSelect"
style="flex:1;min-width:120px;max-width:220px;padding:4px;background:#222;border:1px solid #555;color:#FFF;font-size:10px;font-family:'IBM Plex Mono',monospace;"></select>
<button onclick="loadChapterCollection(document.getElementById('chapterCollectionSelect').value)"
style="padding:4px 8px;background:rgb(0 170 255 / 60%);border:1px solid #0AF;color:#000;font-size:8px;cursor:pointer;">LOAD</button>
<div style="margin-left:auto;display:flex;gap:2px;">
<input type="file" id="importCollectionFile" accept=".json" style="display:none;"
onchange="if(this.files[0])importChapterCollection(this.files[0])">
<button onclick="document.getElementById('importCollectionFile').click()"
style="padding:4px 8px;background:#333;border:1px solid #0FF;color:#0FF;font-size:8px;cursor:pointer;"
title="Import JSON">IMPORT</button>
<button onclick="exportChapterCollection(document.getElementById('chapterCollectionSelect').value)"
style="padding:4px 8px;background:#FA0;border:1px solid #FA0;color:#000;font-size:8px;cursor:pointer;"
title="Download as JSON">EXPORT</button>
<button onclick="deleteChapterCollection(document.getElementById('chapterCollectionSelect').value)"
style="padding:4px 8px;background:#500;border:1px solid #800;color:#F88;font-size:8px;cursor:pointer;">DEL</button>
</div>
</div>
<!-- Collection Name Save Row + Rewind Label -->
<div id="collectionNameRow"
style="display:flex;gap:4px;align-items:center;margin-bottom:4px;font-family:'IBM Plex Mono',monospace;">
<input type="text" id="chapterCollectionName" placeholder="track-name"
style="flex:1;padding:4px;background:#222;border:1px solid #555;color:#0FF;font-size:15px;font-family:'IBM Plex Mono',monospace;">
<button
onclick="document.getElementById('chapterCollectionName').value=generateCuteName();document.getElementById('chapterCollectionName').style.color='#0FF';"
style="padding:4px 8px;background:#333;border:1px solid #0FF;color:#0FF;font-size:8px;cursor:pointer;"
title="Generate new random name">🎲</button>
<button onclick="saveChapterCollection(document.getElementById('chapterCollectionName').value.trim())"
style="padding:4px 8px;background:rgb(0 255 0 / 60%);border:1px solid #0F0;color:#000;font-size:8px;cursor:pointer;">SAVE</button>
<button onclick="clearAllChapters()"
style="padding:4px 8px;background:rgb(255 255 0 / 60%);border:1px solid #FF0;color:#000;font-size:8px;cursor:pointer;">NEW</button>
</div>
<!-- LIVE + Snapshot selector row -->
<div style="display:flex;gap:4px;align-items:center;margin-bottom:8px;flex-wrap:wrap;">
<button id="seqLiveBtn" onclick="toggleSeqLive()"
style="padding:4px 10px;background:rgb(0 255 0 / 60%);border:1px solid #0F0;color:#000;font-size:9px;font-weight:bold;cursor:pointer;font-family:'IBM Plex Mono',monospace;">LIVE
</button>
<div id="snapshotList" style="display:flex;gap:4px;flex-wrap:wrap;"></div>
</div>
<!-- Garden Canvas - Dancing characters plant flowers -->
<div
style="position:relative;background:#000820;border:1px solid #333;overflow:hidden;min-width:320px;height:48px;">
<canvas id="seqGardenCanvas" width="640" height="48"
style="height:48px;image-rendering:pixelated;cursor:pointer;position:absolute;left:50%;transform:translateX(-50%);"></canvas>
</div>
<div
style="color:#555;font-size:9px;font-family:'IBM Plex Mono',monospace;margin-bottom:6px;text-align:center;">🌷
rewind to past bars · 🌳 octave shift · 💕 solo</div>
<!-- Chapter buttons row -->
<div
style="display:flex;align-items:center;gap:8px;justify-content:center;margin-bottom:4px;font-family:'IBM Plex Mono',monospace;flex-wrap:wrap;">
<span style="color:#0FF;font-size:8px;font-weight:bold;">CHAPTERS</span>
<div id="chapterButtonsContainer" style="display:flex;gap:3px;flex-wrap:wrap;justify-content:center;"></div>
</div>
<!-- Visual Canvas - scrollable container for many voices -->
<div id="seqCanvasContainer"
style="position:relative;background:#000;border:1px solid #333;margin-bottom:0;min-width:320px;max-height:280px;overflow-y:auto;">
<canvas id="seqCanvas" width="640" height="160"
style="width:100%;image-rendering:pixelated;cursor:crosshair;"></canvas>
<!-- Scanline overlay -->
<div
style="position:absolute;top:0;left:0;right:0;bottom:0;pointer-events:none;background:repeating-linear-gradient(0deg,transparent,transparent 2px,rgba(0,0,0,0.15) 2px,rgba(0,0,0,0.15) 4px);">
</div>
<!-- Playhead -->
<div id="seqPlayhead"
style="position:absolute;top:0;bottom:0;width:2px;background:#F0F;opacity:0.8;left:0;pointer-events:none;box-shadow:0 0 8px #F0F;">
</div>
</div>
<!-- Show All Voices Toggle -->
<button id="showAllVoicesBtn" onclick="toggleShowAllVoices()"
style="display:none;width:100%;padding:4px 0;margin-bottom:12px;background:#181818;border:1px solid #333;border-top:none;color:#666;font-size:9px;cursor:pointer;font-family:'IBM Plex Mono',monospace;">
SHOW ALL VOICES (0 more)</button>
<!-- Voice Controls with Drift Toggle Buttons + Speed -->
<div style="position:relative;margin-bottom:12px;">
<button onclick="openVoiceConfig()"
style="position:absolute;top:-12px;right:-10px;padding:2px 4px;background:#222;border:1px solid rgb(255 170 0 / 50%);color:#FA0;font-size:8px;cursor:pointer;z-index:10;"
title="Voice Settings">⚙️</button>
<div id="seqDriftControls"
style="display:grid;grid-template-columns:repeat(auto-fill,minmax(90px,1fr));gap:4px;font-family:'IBM Plex Mono',monospace;font-size:8px;max-width:100%;overflow:hidden;min-width:320px;">
<div class="voice-ctrl" style="background:#111;padding:6px;border-radius:4px;text-align:center;">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:4px;">
<div style="display:flex;gap:2px;">
<button id="muteKick" onclick="toggleMute('kick')"
style="padding:2px 4px;background:#222;border:1px solid #333;color:#888;cursor:pointer;font-size:7px;"
title="Mute">M</button>
<button id="soloKick" onclick="toggleSolo('kick')"
style="padding:2px 4px;background:#222;border:1px solid #333;color:#888;cursor:pointer;font-size:7px;"
title="Solo">S</button>
</div>
<span style="color:#F44;font-size:9px;">KICK</span>
</div>
<div style="display:flex;gap:2px;margin-bottom:4px;">
<button id="driftKickL" onclick="setDrift('kick','left')"
style="flex:1;padding:4px;background:#222;border:1px solid #333;color:#888;cursor:pointer;font-size:10px;"></button>
<button id="driftKickS" onclick="setDrift('kick','none')"
style="flex:1;padding:4px;background:#F0F;border:1px solid #F0F;color:#000;cursor:pointer;font-size:7px;"></button>
<button id="driftKickR" onclick="setDrift('kick','right')"
style="flex:1;padding:4px;background:#222;border:1px solid #333;color:#888;cursor:pointer;font-size:10px;"></button>
</div>
<input type="range" id="driftSpeedKick" min="1" max="8" value="4" style="width:100%;height:8px;"
title="Speed">
</div>
<div class="voice-ctrl" style="background:#111;padding:6px;border-radius:4px;text-align:center;">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:4px;">
<div style="display:flex;gap:2px;">
<button id="muteSnare" onclick="toggleMute('snare')"
style="padding:2px 4px;background:#222;border:1px solid #333;color:#888;cursor:pointer;font-size:7px;"
title="Mute">M</button>
<button id="soloSnare" onclick="toggleSolo('snare')"
style="padding:2px 4px;background:#222;border:1px solid #333;color:#888;cursor:pointer;font-size:7px;"
title="Solo">S</button>
</div>
<span style="color:#FA4;font-size:9px;">SNARE</span>
</div>
<div style="display:flex;gap:2px;margin-bottom:4px;">
<button id="driftSnareL" onclick="setDrift('snare','left')"
style="flex:1;padding:4px;background:#222;border:1px solid #333;color:#888;cursor:pointer;font-size:10px;"></button>
<button id="driftSnareS" onclick="setDrift('snare','none')"
style="flex:1;padding:4px;background:#F0F;border:1px solid #F0F;color:#000;cursor:pointer;font-size:7px;"></button>
<button id="driftSnareR" onclick="setDrift('snare','right')"
style="flex:1;padding:4px;background:#222;border:1px solid #333;color:#888;cursor:pointer;font-size:10px;"></button>
</div>
<input type="range" id="driftSpeedSnare" min="1" max="8" value="4" style="width:100%;height:8px;"
title="Speed">
</div>
<div class="voice-ctrl" style="background:#111;padding:6px;border-radius:4px;text-align:center;">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:4px;">
<div style="display:flex;gap:2px;">
<button id="muteHihat" onclick="toggleMute('hihat')"
style="padding:2px 4px;background:#222;border:1px solid #333;color:#888;cursor:pointer;font-size:7px;"
title="Mute">M</button>
<button id="soloHihat" onclick="toggleSolo('hihat')"
style="padding:2px 4px;background:#222;border:1px solid #333;color:#888;cursor:pointer;font-size:7px;"
title="Solo">S</button>
</div>
<span style="color:#FF4;font-size:9px;">HIHAT</span>
</div>
<div style="display:flex;gap:2px;margin-bottom:4px;">
<button id="driftHihatL" onclick="setDrift('hihat','left')"
style="flex:1;padding:4px;background:#222;border:1px solid #333;color:#888;cursor:pointer;font-size:10px;"></button>
<button id="driftHihatS" onclick="setDrift('hihat','none')"
style="flex:1;padding:4px;background:#F0F;border:1px solid #F0F;color:#000;cursor:pointer;font-size:7px;"></button>
<button id="driftHihatR" onclick="setDrift('hihat','right')"
style="flex:1;padding:4px;background:#222;border:1px solid #333;color:#888;cursor:pointer;font-size:10px;"></button>
</div>
<input type="range" id="driftSpeedHihat" min="1" max="8" value="4" style="width:100%;height:8px;"
title="Speed">
</div>
<div class="voice-ctrl" style="background:#111;padding:6px;border-radius:4px;text-align:center;">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:4px;">
<div style="display:flex;gap:2px;">
<button id="muteBass" onclick="toggleMute('bass')"
style="padding:2px 4px;background:#222;border:1px solid #333;color:#888;cursor:pointer;font-size:7px;"
title="Mute">M</button>
<button id="soloBass" onclick="toggleSolo('bass')"
style="padding:2px 4px;background:#222;border:1px solid #333;color:#888;cursor:pointer;font-size:7px;"
title="Solo">S</button>
</div>
<span style="color:#4F4;font-size:9px;">BASS</span>
</div>
<div style="display:flex;gap:2px;margin-bottom:4px;">
<button id="driftBassL" onclick="setDrift('bass','left')"
style="flex:1;padding:4px;background:#222;border:1px solid #333;color:#888;cursor:pointer;font-size:10px;"></button>
<button id="driftBassS" onclick="setDrift('bass','none')"
style="flex:1;padding:4px;background:#F0F;border:1px solid #F0F;color:#000;cursor:pointer;font-size:7px;"></button>
<button id="driftBassR" onclick="setDrift('bass','right')"
style="flex:1;padding:4px;background:#222;border:1px solid #333;color:#888;cursor:pointer;font-size:10px;"></button>
</div>
<input type="range" id="driftSpeedBass" min="1" max="8" value="4" style="width:100%;height:8px;"
title="Speed">
</div>
<div class="voice-ctrl" style="background:#111;padding:6px;border-radius:4px;text-align:center;">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:4px;">
<div style="display:flex;gap:2px;">
<button id="mutePad" onclick="toggleMute('pad')"
style="padding:2px 4px;background:#222;border:1px solid #333;color:#888;cursor:pointer;font-size:7px;"
title="Mute">M</button>
<button id="soloPad" onclick="toggleSolo('pad')"
style="padding:2px 4px;background:#222;border:1px solid #333;color:#888;cursor:pointer;font-size:7px;"
title="Solo">S</button>
</div>
<span style="color:#4FF;font-size:9px;">PAD</span>
</div>
<div style="display:flex;gap:2px;margin-bottom:4px;">
<button id="driftPadL" onclick="setDrift('pad','left')"
style="flex:1;padding:4px;background:#222;border:1px solid #333;color:#888;cursor:pointer;font-size:10px;"></button>
<button id="driftPadS" onclick="setDrift('pad','none')"
style="flex:1;padding:4px;background:#F0F;border:1px solid #F0F;color:#000;cursor:pointer;font-size:7px;"></button>
<button id="driftPadR" onclick="setDrift('pad','right')"
style="flex:1;padding:4px;background:#222;border:1px solid #333;color:#888;cursor:pointer;font-size:10px;"></button>
</div>
<input type="range" id="driftSpeedPad" min="1" max="8" value="4" style="width:100%;height:8px;"
title="Speed">
</div>
</div>
</div>
<!-- BPM row with SWING, SYNC, voice settings -->
<div id="seqInfoRow1"
style="display:flex;gap:8px;margin-bottom:6px;font-family:'IBM Plex Mono',monospace;font-size:9px;align-items:center;min-width:320px;flex-wrap:wrap;">
<div style="display:flex;align-items:center;gap:4px;">
<span style="color:#F0F;font-size:7px;">BPM</span>
<input type="number" id="seqBpmInput" value="90" min="30" max="300"
oninput="applySeqSetting('bpm',this.value)"
style="width:55px;padding:4px;background:#000;border:1px solid #F0F;color:#F0F;font-size:10px;font-family:inherit;">
<button id="bpmLockBtn" onclick="toggleBpmLock()"
style="padding:2px 6px;background:#222;border:1px solid #555;color:#888;font-size:10px;cursor:pointer;"
title="Lock BPM">🔓</button>
</div>
<div style="display:flex;align-items:center;gap:4px;">
<span style="color:#F0F;font-size:7px;">SWING</span>
<input type="range" id="seqSwing" min="0" max="75" value="0" style="width:50px;height:6px;"
oninput="applySeqSetting('swing',this.value)">
<span id="seqSwingVal" style="color:#F0F;font-size:8px;width:20px;">0%</span>
</div>
<div style="flex-grow:1;"></div>
<div id="seqActionBtns" style="display:flex;gap:4px;align-items:center;">
<button onclick="randomizeSeqPattern()"
style="padding:4px 8px;background:rgb(255 0 255 / 80%);border:1px solid #F0F;color:#000;font-size:12px;cursor:pointer;font-family:'IBM Plex Mono',monospace;font-weight:bold;">RANDOM</button>
<button onclick="clearSeqPattern()"
style="padding:4px 8px;background:#222;border:1px solid #666;color:#888;font-size:12px;cursor:pointer;font-family:'IBM Plex Mono',monospace;">WIPE</button>
<button id="ghostMuteBtn" onclick="toggleGhostMute()"
style="padding:4px 8px;background:#222;border:1px solid #FFF;color:#FFF;font-size:12px;cursor:pointer;"
title="Toggle ghost patterns">GHOSTS</button>
<button onclick="randomizeGhostPatterns()"
style="padding:4px 8px;background:#222;border:1px solid #0FF;color:#0FF;font-size:12px;cursor:pointer;"
title="Randomize ghost patterns">GHOSTROLL</button>
<button id="syncPatternsBtn" onclick="syncPatternsToGhost()"
style="padding:4px 8px;background:#222;border:1px solid #0F0;color:#0F0;font-size:12px;cursor:pointer;"
title="Sync visual to ghost">SYNC</button>
</div>
</div>
<!-- Chaos/Rally/Evolve/GhostProb/Gain row - moved up after buttons -->
<div id="seqInfoRow2"
style="display:grid;grid-template-columns:1fr 1fr 1fr 1fr 1fr;gap:12px;margin-bottom:8px;font-family:'IBM Plex Mono',monospace;font-size:9px;min-width:320px;max-width:700px;">
<div>
<div class="seq-label" style="color:#F0F;font-size:7px;margin-bottom:2px;">CHAOS</div>
<input type="range" id="seqChaos" min="0" max="100" value="30" style="width:100%;height:12px;">
</div>
<div>
<div class="seq-label" style="color:#FF0;font-size:7px;margin-bottom:2px;">RALLY</div>
<input type="range" id="seqRally" min="0" max="100" value="50" style="width:100%;height:12px;">
</div>
<div>
<div class="seq-label" style="color:#666;font-size:7px;margin-bottom:2px;">EVOLVE</div>
<input type="range" id="seqEvolveRate" min="0" max="100" value="50"
style="width:100%;height:12px;accent-color:#666;" oninput="setEvolveRate(this.value)">
</div>
<div>
<div class="seq-label" style="color:#888;font-size:7px;margin-bottom:2px;">GHOST PROB</div>
<input type="range" id="seqGhostProb" min="0" max="100" value="60"
style="width:100%;height:12px;accent-color:#888;" oninput="setGhostProb(this.value)">
</div>
<div>
<div class="seq-label" style="color:#0FF;font-size:7px;margin-bottom:2px;">GAIN</div>
<input type="range" id="seqMasterGain" min="0" max="200" value="100"
style="width:100%;height:12px;accent-color:#0FF;" oninput="setMasterGain(this.value)">
</div>
</div>
<!-- Dropdowns row: Scale | Kit + phrase | Lead + phrase | Chaos -->
<div
style="display:flex;gap:8px;margin-bottom:8px;font-family:'IBM Plex Mono',monospace;font-size:9px;align-items:center;flex-wrap:wrap;min-width:320px;">
<!-- SCALE -->
<div style="display:flex;align-items:center;gap:4px;">
<span style="color:#0AF;font-size:7px;">SCALE</span>
<select id="seqScaleSelect"
style="padding:4px;background:#000;border:1px solid #0AF;color:#0AF;font-size:8px;font-family:inherit;"
onchange="applySeqSetting('scale',this.value)">
<option value="minor">minor</option>
<option value="major">major</option>
<option value="dorian">dorian</option>
<option value="phrygian">phrygian</option>
<option value="pentatonic">penta</option>
<option value="mixolydian">mixo</option>
</select>
</div>
<!-- KIT: name [select] [phrase] -->
<div style="display:flex;align-items:center;gap:4px;">
<span style="color:#0F0;font-size:7px;">KIT:</span>
<span id="seqKitLive"
style="color:#0F0;font-size:10px;font-weight:bold;max-width:100px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;"
title="Current Kit">TR-808</span>
<select id="seqKitSelect"
style="padding:4px;background:#000;border:1px solid #0F0;color:#0F0;font-size:8px;font-family:inherit;max-width:90px;"
onchange="applySeqSetting('kit',this.value)">
<option value="">(Random)</option>
<!-- Options will now be injected by JavaScript -->
</select>
<select id="seqKitPhrase"
style="padding:4px;background:#000;border:1px solid #0F0;color:#0F0;font-size:8px;font-family:inherit;"
onchange="applySeqSetting('kitPhrase',this.value)"
title="Kit rotation timing (escalating probability, forced at max)">
<option value="8">8</option>
<option value="12">12</option>
<option value="16">16</option>
<option value="24">24</option>
<option value="32" selected>32</option>
<option value="48">48</option>
<option value="64">64</option>
<option value="-1">🔒</option>
</select>
</div>
<!-- LEAD: name [select] [phrase] -->
<div style="display:flex;align-items:center;gap:4px;">
<span style="color:#FA0;font-size:7px;">LEAD:</span>
<span id="seqMotifLive"
style="color:#FA0;font-size:10px;font-weight:bold;max-width:60px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;"
title="Current Motif">chip</span>
<select id="seqMotifVoice"
style="padding:4px;background:#000;border:1px solid #FA0;color:#FA0;font-size:8px;font-family:inherit;max-width:80px;"
onchange="applySeqSetting('motif',this.value)">
<option value="">(Random)</option>
<option value="sidLead">SID Lead</option>
<option value="chipLead">Chip Lead</option>
<option value="marsLead">Mars Lead</option>
<option value="xpoly">Poly</option>
<option value="xwave">GB Wave</option>
<option value="fmWhammy">FM Wham</option>
<option value="fmPad">FM Pad</option>
<option value="fmBrass">FM Brass</option>
</select>
<select id="seqMotifBars"
style="padding:4px;background:#000;border:1px solid #FA0;color:#FA0;font-size:8px;font-family:inherit;"
onchange="applySeqSetting('bars',this.value)"
title="Motif phrase length (bars before motif may regenerate)">
<option value="0">🎲</option>
<option value="1">1</option>
<option value="2">2</option>
<option value="3">3</option>
<option value="4" selected>4</option>
<option value="6">6</option>
<option value="8">8</option>
<option value="12">12</option>
<option value="16">16</option>
<option value="32">32</option>
<option value="64">64</option>
<option value="-1">🔒</option>
</select>
</div>
<!-- APHEX Chaos -->
<div style="display:flex;align-items:center;gap:4px;">
<span title="Aphex Chaos: Glitches/Echoes/Blur" style="color:#FA0;font-size:7px;cursor:help">APHX</span>
<input type="range" id="seqMotifChaos" min="0" max="200" value="100"
style="width:40px;height:6px;accent-color:#FA0;" oninput="applySeqSetting('motifChaos', this.value/100)"
title="Aphex Motif Chaos Level (0-200%)">
</div>
</div>
<!-- Motif Panel + Oscillator Row (50% + 50%) -->
<div style="display:flex;gap:8px;margin-bottom:12px;width:100%;">
<!-- LEFT: Motif Panel (50%) -->
<div id="leadVoicePanel" class="scanlines"
style="position:relative;display:inline-block;width:calc(50% - 4px);vertical-align:middle;background:#111;border:1px solid #FA0;border-radius:4px;padding:8px;">
<!-- CONSOLIDATED HEADER: Voice Selector | Mode Selector | DEEP -->
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:6px;gap:6px;">
<!-- LEFT: Voice selector for motif target -->
<select id="voicePanelSelector"
style="padding:2px 4px;background:#000;border:1px solid #FA0;color:#FA0;font-size:9px;font-family:inherit;min-width:50px;"
onchange="switchVoicePanel(this.value)">
<option value="lead" selected>LEAD</option>
<option value="kick">KICK</option>
<option value="snare">SNARE</option>
<option value="hihat">HIHAT</option>
<option value="bass">BASS</option>
<option value="pad">PAD</option>
</select>
<!-- CENTER: Mode/Synth selector (only for lead) -->
<select id="voicePanelMode"
style="flex:1;padding:2px 4px;background:#000;border:1px solid #FA0;color:#FA0;font-size:9px;font-family:inherit;max-width:100px;"
onchange="switchVoiceMode(this.value)">
<option value="">(Random)</option>
<option value="xwave">GB Wave</option>
<option value="sidLead">SID Lead</option>
<option value="chipLead">Chip Lead</option>
<option value="marsLead">Mars Lead</option>
<option value="xpoly">Poly</option>
<option value="fmWhammy">FM Wham</option>
<option value="fmPad">FM Pad</option>
<option value="fmBrass">FM Brass</option>
<option value="xwaveX">GB Wave×</option>
<option value="xwave3d">GB Wave 3D</option>
</select>
<!-- RIGHT: DEEP button (opens voice-specific settings) -->
<button id="voiceDeepBtn" onclick="openVoiceDeepSettings()"
style="background:rgba(0,0,0,0.4);border:1px solid #FA0;color:#FA0;font-size:10px;padding:2px 6px;border-radius:3px;cursor:pointer;">
DEEP</button>
</div>
<!-- Lead-only GAIN slider -->
<div id="leadGainRow" style="display:flex;align-items:center;gap:6px;margin-bottom:6px;padding:4px 0;">
<span style="color:#F0F;font-size:8px;min-width:32px;">GAIN</span>
<input type="range" id="leadMotifGain" min="0" max="200" value="100"
style="flex:1;height:6px;accent-color:#F0F;"
oninput="setLeadMotifGain(this.value)"
title="Lead voice gain (0-200%)">
<span id="leadMotifGainVal" style="color:#F0F;font-size:8px;min-width:28px;">100%</span>
</div>
<!-- LEAD Universal Quick Controls -->
<div id="leadUniversalControls" class="voice-quick-controls" style="display:none;padding:6px 0;">
<div style="display:flex;gap:8px;flex-wrap:wrap;align-items:center;">
<div style="display:flex;align-items:center;gap:4px;">
<span style="color:#F0F;font-size:8px;min-width:32px;">ATK</span>
<input type="range" id="leadQuickAttack" min="1" max="500" value="10"
style="width:50px;height:6px;accent-color:#F0F;"
oninput="setLeadUniversalParam('attack',this.value)"
title="Attack time (ms)">
<span id="leadQuickAttackVal" style="color:#F0F;font-size:8px;min-width:28px;">10ms</span>
</div>
<div style="display:flex;align-items:center;gap:4px;">
<span style="color:#F0F;font-size:8px;min-width:32px;">REL</span>
<input type="range" id="leadQuickRelease" min="10" max="1000" value="250"
style="width:50px;height:6px;accent-color:#F0F;"
oninput="setLeadUniversalParam('release',this.value)"
title="Release time (ms)">
<span id="leadQuickReleaseVal" style="color:#F0F;font-size:8px;min-width:32px;">250ms</span>
</div>
<div style="display:flex;align-items:center;gap:4px;">
<span style="color:#F0F;font-size:8px;min-width:28px;">CUT</span>
<input type="range" id="leadQuickCutoff" min="200" max="8000" value="3000"
style="width:50px;height:6px;accent-color:#F0F;"
oninput="setLeadUniversalParam('cutoff',this.value)"
title="Filter cutoff (Hz)">
<span id="leadQuickCutoffVal" style="color:#F0F;font-size:8px;min-width:28px;">3k</span>
</div>
<div style="display:flex;align-items:center;gap:4px;">
<span style="color:#F0F;font-size:8px;min-width:28px;">RES</span>
<input type="range" id="leadQuickReso" min="0" max="20" value="5"
style="width:40px;height:6px;accent-color:#F0F;"
oninput="setLeadUniversalParam('reso',this.value)"
title="Filter resonance">
<span id="leadQuickResoVal" style="color:#F0F;font-size:8px;min-width:20px;">5</span>
</div>
<div style="display:flex;align-items:center;gap:4px;">
<span style="color:#F0F;font-size:8px;min-width:36px;">DET</span>
<input type="range" id="leadQuickDetune" min="0" max="50" value="0"
style="width:40px;height:6px;accent-color:#F0F;"
oninput="setLeadUniversalParam('detune',this.value)"
title="Detune (cents)">
<span id="leadQuickDetuneVal" style="color:#F0F;font-size:8px;min-width:20px;">0c</span>
</div>
</div>
</div>
<!-- KICK Quick Controls -->
<div id="voiceQuick-kick" class="voice-quick-controls" style="display:none;padding:6px 0;">
<div style="display:flex;gap:12px;align-items:center;flex-wrap:wrap;">
<div style="display:flex;align-items:center;gap:4px;">
<span style="color:#F00;font-size:8px;min-width:36px;">PITCH</span>
<input type="range" id="kickQuickPitch" min="30" max="120" value="55"
style="width:70px;height:6px;accent-color:#F00;"
oninput="setVoiceQuickParam('kick','pitch',this.value)"
title="Kick fundamental pitch (Hz)">
<span id="kickQuickPitchVal" style="color:#F00;font-size:8px;min-width:32px;">55Hz</span>
</div>
<div style="display:flex;align-items:center;gap:4px;">
<span style="color:#F00;font-size:8px;min-width:36px;">DECAY</span>
<input type="range" id="kickQuickDecay" min="50" max="400" value="150"
style="width:70px;height:6px;accent-color:#F00;"
oninput="setVoiceQuickParam('kick','decay',this.value)"
title="Kick decay time (ms)">
<span id="kickQuickDecayVal" style="color:#F00;font-size:8px;min-width:36px;">150ms</span>
</div>
<button onclick="resetVoiceQuick('kick')" style="padding:2px 6px;background:rgba(0,0,0,0.4);border:1px solid #F00;color:#F00;font-size:8px;cursor:pointer;border-radius:3px;" title="Reset to defaults"></button>
</div>
</div>
<!-- SNARE Quick Controls -->
<div id="voiceQuick-snare" class="voice-quick-controls" style="display:none;padding:6px 0;">
<div style="display:flex;gap:12px;align-items:center;flex-wrap:wrap;">
<div style="display:flex;align-items:center;gap:4px;">
<span style="color:#FA0;font-size:8px;min-width:42px;">SNAPPY</span>
<input type="range" id="snareQuickSnappy" min="0" max="100" value="60"
style="width:70px;height:6px;accent-color:#FA0;"
oninput="setVoiceQuickParam('snare','snappy',this.value)"
title="Snare snappiness/attack">
<span id="snareQuickSnappyVal" style="color:#FA0;font-size:8px;min-width:28px;">60%</span>
</div>
<div style="display:flex;align-items:center;gap:4px;">
<span style="color:#FA0;font-size:8px;min-width:36px;">DECAY</span>
<input type="range" id="snareQuickDecay" min="50" max="400" value="150"
style="width:70px;height:6px;accent-color:#FA0;"
oninput="setVoiceQuickParam('snare','decay',this.value)"
title="Snare decay time (ms)">
<span id="snareQuickDecayVal" style="color:#FA0;font-size:8px;min-width:36px;">150ms</span>
</div>
<button onclick="resetVoiceQuick('snare')" style="padding:2px 6px;background:rgba(0,0,0,0.4);border:1px solid #FA0;color:#FA0;font-size:8px;cursor:pointer;border-radius:3px;" title="Reset to defaults"></button>
</div>
</div>
<!-- HIHAT Quick Controls -->
<div id="voiceQuick-hihat" class="voice-quick-controls" style="display:none;padding:6px 0;">
<div style="display:flex;gap:12px;align-items:center;flex-wrap:wrap;">
<div style="display:flex;align-items:center;gap:4px;">
<span style="color:#FF0;font-size:8px;min-width:28px;">MIX</span>
<input type="range" id="hihatQuickMix" min="0" max="100" value="5"
style="width:60px;height:6px;accent-color:#FF0;"
oninput="setVoiceQuickParam('hihat','mix',this.value)"
title="Open/Closed mix (0%=closed, 100%=open)">
<span id="hihatQuickMixVal" style="color:#FF0;font-size:8px;min-width:28px;">5%</span>
</div>
<div style="display:flex;align-items:center;gap:4px;">
<span style="color:#FF0;font-size:8px;min-width:36px;">TONE</span>
<input type="range" id="hihatQuickTone" min="4000" max="14000" value="8000"
style="width:60px;height:6px;accent-color:#FF0;"
oninput="setVoiceQuickParam('hihat','tone',this.value)"
title="Hihat tone frequency (Hz)">
<span id="hihatQuickToneVal" style="color:#FF0;font-size:8px;min-width:32px;">8kHz</span>
</div>
<div style="display:flex;align-items:center;gap:4px;">
<span style="color:#FF0;font-size:8px;min-width:32px;">RING</span>
<input type="range" id="hihatQuickRing" min="0" max="100" value="30"
style="width:50px;height:6px;accent-color:#FF0;"
oninput="setVoiceQuickParam('hihat','ring',this.value)"
title="Metallic ring amount">
<span id="hihatQuickRingVal" style="color:#FF0;font-size:8px;min-width:28px;">30%</span>
</div>
<button onclick="resetVoiceQuick('hihat')" style="padding:2px 6px;background:rgba(0,0,0,0.4);border:1px solid #FF0;color:#FF0;font-size:8px;cursor:pointer;border-radius:3px;" title="Reset to defaults"></button>
</div>
</div>
<!-- BASS Quick Controls -->
<div id="voiceQuick-bass" class="voice-quick-controls" style="display:none;padding:6px 0;">
<div style="display:flex;gap:12px;align-items:center;flex-wrap:wrap;">
<div style="display:flex;align-items:center;gap:4px;">
<span style="color:#0AF;font-size:8px;min-width:36px;">FILTER</span>
<input type="range" id="bassQuickFilter" min="200" max="4000" value="1200"
style="width:70px;height:6px;accent-color:#0AF;"
oninput="setVoiceQuickParam('bass','filter',this.value)"
title="Bass filter cutoff (Hz)">
<span id="bassQuickFilterVal" style="color:#0AF;font-size:8px;min-width:36px;">1.2kHz</span>
</div>
<button onclick="resetVoiceQuick('bass')" style="padding:2px 6px;background:rgba(0,0,0,0.4);border:1px solid #0AF;color:#0AF;font-size:8px;cursor:pointer;border-radius:3px;" title="Reset to defaults"></button>
</div>
</div>
<!-- PAD Quick Controls -->
<div id="voiceQuick-pad" class="voice-quick-controls" style="display:none;padding:6px 0;">
<div style="display:flex;gap:12px;align-items:center;flex-wrap:wrap;">
<div style="display:flex;align-items:center;gap:4px;">
<span style="color:#0F0;font-size:8px;min-width:36px;">ATK</span>
<input type="range" id="padQuickAttack" min="10" max="1000" value="70"
style="width:55px;height:6px;accent-color:#0F0;"
oninput="setVoiceQuickParam('pad','attack',this.value)"
title="Pad attack time (ms)">
<span id="padQuickAttackVal" style="color:#0F0;font-size:8px;min-width:36px;">70ms</span>
</div>
<div style="display:flex;align-items:center;gap:4px;">
<span style="color:#0F0;font-size:8px;min-width:32px;">CUT</span>
<input type="range" id="padQuickFilter" min="500" max="8000" value="2000"
style="width:55px;height:6px;accent-color:#0F0;"
oninput="setVoiceQuickParam('pad','filter',this.value)"
title="Pad filter cutoff (Hz)">
<span id="padQuickFilterVal" style="color:#0F0;font-size:8px;min-width:32px;">2kHz</span>
</div>
<div style="display:flex;align-items:center;gap:4px;">
<span style="color:#0F0;font-size:8px;min-width:32px;">RES</span>
<input type="range" id="padQuickReso" min="0" max="20" value="5"
style="width:45px;height:6px;accent-color:#0F0;"
oninput="setVoiceQuickParam('pad','reso',this.value)"
title="Pad filter resonance">
<span id="padQuickResoVal" style="color:#0F0;font-size:8px;min-width:20px;">5</span>
</div>
<button onclick="resetVoiceQuick('pad')" style="padding:2px 6px;background:rgba(0,0,0,0.4);border:1px solid #0F0;color:#0F0;font-size:8px;cursor:pointer;border-radius:3px;" title="Reset to defaults"></button>
</div>
</div>
<!-- GB Wave controls -->
<div id="leadConfig-xwave" class="lead-config" style="display:none;min-height:36px;">
<div style="display:flex;gap:4px;margin-bottom:4px;">
<button onclick="setXWaveTheme(0)" id="xwaveThemeDMG" class="xwave-theme-btn"
style="padding:2px 6px;background:#0F380F;border:1px solid #9BBC0F;color:#9BBC0F;font-size:7px;cursor:pointer;">DMG</button>
<button onclick="setXWaveTheme(1)" id="xwaveThemeC64" class="xwave-theme-btn"
style="padding:2px 6px;background:#40318D;border:1px solid #70A4B2;color:#70A4B2;font-size:7px;cursor:pointer;">C64</button>
<button onclick="setXWaveTheme(2)" id="xwaveThemeCRT" class="xwave-theme-btn"
style="padding:2px 6px;background:#1A0F00;border:1px solid #FFB000;color:#FFB000;font-size:7px;cursor:pointer;">CRT</button>
</div>
<div id="xwaveScreenContainer" style="position:relative;margin-bottom:6px;">
<canvas id="xwaveCanvas" width="256" height="64"
style="width:100%;height:48px;background:#0F380F;border:1px solid #306230;cursor:crosshair;image-rendering:pixelated;"></canvas>
<div id="xwaveScanlines"
style="position:absolute;top:0;left:0;right:0;bottom:0;pointer-events:none;background:linear-gradient(rgba(18,16,16,0) 50%,rgba(0,0,0,0.08) 50%),linear-gradient(90deg,rgba(255,0,0,0.02),rgba(0,255,0,0.015),rgba(0,0,255,0.02));background-size:100% 2px,3px 100%;">
</div>
</div>
<div id="xwaveButtons" style="display:flex;flex-wrap:wrap;">
<button onclick="setXWavePreset('sine')" class="xwave-btn"
style="padding:2px 6px;background:#222;border:1px solid #9BBC0F;color:#9BBC0F;font-size:7px;cursor:pointer;">SIN</button>
<button onclick="setXWavePreset('saw')" class="xwave-btn"
style="padding:2px 6px;background:#222;border:1px solid #9BBC0F;color:#9BBC0F;font-size:7px;cursor:pointer;">SAW</button>
<button onclick="setXWavePreset('square')" class="xwave-btn"
style="padding:2px 6px;background:#222;border:1px solid #9BBC0F;color:#9BBC0F;font-size:7px;cursor:pointer;">SQR</button>
<button onclick="setXWavePreset('tri')" class="xwave-btn"
style="padding:2px 6px;background:#222;border:1px solid #9BBC0F;color:#9BBC0F;font-size:7px;cursor:pointer;">TRI</button>
<button onclick="setXWavePreset('pulse25')" class="xwave-btn"
style="padding:2px 6px;background:#222;border:1px solid #9BBC0F;color:#9BBC0F;font-size:7px;cursor:pointer;">P25</button>
<button onclick="setXWavePreset('organ')" class="xwave-btn"
style="padding:2px 6px;background:#222;border:1px solid #9BBC0F;color:#9BBC0F;font-size:7px;cursor:pointer;">ORG</button>
<button onclick="setXWavePreset('bass')" class="xwave-btn"
style="padding:2px 6px;background:#222;border:1px solid #9BBC0F;color:#9BBC0F;font-size:7px;cursor:pointer;">BAS</button>
<button onclick="setXWavePreset('noise')" class="xwave-btn"
style="padding:2px 6px;background:#222;border:1px solid #9BBC0F;color:#9BBC0F;font-size:7px;cursor:pointer;">NSE</button>
<button onclick="randomizeXWave()" class="xwave-btn"
style="padding:2px 6px;background:#222;border:1px solid #FF0;color:#FF0;font-size:7px;cursor:pointer;">RND</button>
</div>
</div>
<!-- SID Lead controls -->
<div id="leadConfig-sidLead" class="lead-config" style="display:none;min-height:36px;">
<div style="color:#70A4B2;font-size:8px;margin-bottom:4px;">C64 SID CHIP</div>
<div style="display:flex;gap:8px;flex-wrap:wrap;align-items:center;">
<label style="color:#FFF;font-size:7px;">GAIN <input type="range" id="sidGain" min="0" max="200"
value="100" style="width:50px;height:4px;" oninput="updateSidParam('gain',this.value)"><span
id="sidGainVal" style="color:#70A4B2;font-size:7px;width:24px;">100%</span></label>
<label style="color:#888;font-size:7px;">PULSEWIDTH <input type="range" id="sidPulseWidth" min="10"
max="90" value="50" style="width:60px;height:4px;" oninput="updateSidParam('pw',this.value)"><span
id="sidPwVal" style="color:#70A4B2;font-size:7px;width:20px;">50</span></label>
<label style="color:#888;font-size:7px;">DETUNE <input type="range" id="sidDetune" min="0" max="30"
value="8" style="width:50px;height:4px;" oninput="updateSidParam('detune',this.value)"><span
id="sidDetuneVal" style="color:#70A4B2;font-size:7px;width:16px;">8</span></label>
<label style="color:#888;font-size:7px;">FILTER <input type="range" id="sidFilter" min="500" max="8000"
value="3000" style="width:50px;height:4px;" oninput="updateSidParam('filter',this.value)"><span
id="sidFilterVal" style="color:#70A4B2;font-size:7px;width:28px;">3k</span></label>
</div>
</div>
<!-- Mars Lead controls -->
<div id="leadConfig-marsLead" class="lead-config" style="display:none;min-height:36px;">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:4px;">
<span style="color:#FF4;font-size:8px;">MARS TOY LEAD</span>
<div style="display:flex;gap:4px;">
<button onclick="mutateMarsParams()" style="padding:2px 6px;background:rgba(0,0,0,0.4);border:1px solid #FF4;color:#FF4;font-size:7px;cursor:pointer;border-radius:3px;" title="Mutate all params randomly">🎲 MUTATE</button>
<button onclick="resetMarsParams()" style="padding:2px 6px;background:rgba(0,0,0,0.4);border:1px solid #888;color:#888;font-size:7px;cursor:pointer;border-radius:3px;" title="Reset to defaults"></button>
</div>
</div>
<div style="display:flex;gap:8px;flex-wrap:wrap;align-items:center;">
<label style="color:#FFF;font-size:7px;">GAIN <input type="range" id="marsGain" min="0" max="200"
value="100" style="width:50px;height:4px;" oninput="updateMarsParam('gain',this.value)"><span
id="marsGainVal" style="color:#FF4;font-size:7px;width:24px;">100%</span></label>
<label style="color:#888;font-size:7px;">WOBBLE <input type="range" id="marsWobble" min="0" max="100"
value="35" style="width:50px;height:4px;" oninput="updateMarsParam('wobble',this.value)"
title="Tape drift depth (dying battery effect)"><span
id="marsWobbleVal" style="color:#FF4;font-size:7px;width:20px;">35</span></label>
<label style="color:#888;font-size:7px;">SPEED <input type="range" id="marsSpeed" min="5" max="40"
value="15" style="width:50px;height:4px;" oninput="updateMarsParam('speed',this.value)"
title="Wobble rate (Hz × 10)"><span
id="marsSpeedVal" style="color:#FF4;font-size:7px;width:20px;">1.5</span></label>
<label style="color:#888;font-size:7px;">FORMANT <input type="range" id="marsFormant" min="600" max="2500"
value="1200" style="width:50px;height:4px;" oninput="updateMarsParam('formant',this.value)"
title="Filter 'mouth' position"><span
id="marsFormantVal" style="color:#FF4;font-size:7px;width:28px;">1.2k</span></label>
<label style="color:#888;font-size:7px;">CLICK <input type="range" id="marsClick" min="0" max="100"
value="50" style="width:40px;height:4px;" oninput="updateMarsParam('click',this.value)"
title="Key click attack"><span
id="marsClickVal" style="color:#FF4;font-size:7px;width:20px;">50</span></label>
</div>
</div>
<!-- Chip Lead controls -->
<div id="leadConfig-chipLead" class="lead-config" style="display:none;min-height:36px;">
<div style="color:#0FF;font-size:8px;margin-bottom:4px;">8-BIT CHIP PWM</div>
<div style="display:flex;gap:8px;flex-wrap:wrap;align-items:center;">
<label style="color:#FFF;font-size:7px;">GAIN <input type="range" id="chipGain" min="0" max="200"
value="100" style="width:50px;height:4px;" oninput="updateChipParam('gain',this.value)"><span
id="chipGainVal" style="color:#0FF;font-size:7px;width:24px;">100%</span></label>
<label style="color:#888;font-size:7px;">PULSEWIDTH <input type="range" id="chipPulseWidth" min="10"
max="90" value="35" style="width:50px;height:4px;" oninput="updateChipParam('pw',this.value)"><span
id="chipPwVal" style="color:#0FF;font-size:7px;width:20px;">35</span></label>
<label style="color:#888;font-size:7px;">ARP <input type="range" id="chipArpSpeed" min="0" max="100"
value="50" style="width:40px;height:4px;" oninput="updateChipParam('arp',this.value)"><span
id="chipArpVal" style="color:#0FF;font-size:7px;width:20px;">50</span></label>
<label style="color:#888;font-size:7px;">VIB <input type="range" id="chipVibrato" min="0" max="100"
value="30" style="width:40px;height:4px;" oninput="updateChipParam('vib',this.value)"><span
id="chipVibVal" style="color:#0FF;font-size:7px;width:20px;">30</span></label>
</div>
</div>
<!-- FM Whammy controls -->
<div id="leadConfig-fmWhammy" class="lead-config" style="display:none;min-height:36px;">
<div style="color:#F0F;font-size:8px;margin-bottom:4px;">FM PITCH WHAMMY</div>
<div style="display:flex;gap:8px;flex-wrap:wrap;align-items:center;">
<label style="color:#FFF;font-size:7px;">GAIN <input type="range" id="fmGain" min="0" max="200"
value="100" style="width:50px;height:4px;" oninput="updateFmParam('gain',this.value)"><span
id="fmGainVal" style="color:#F0F;font-size:7px;width:24px;">100%</span></label>
<label style="color:#888;font-size:7px;">BEND <input type="range" id="fmBendDepth" min="0" max="24"
value="12" style="width:40px;height:4px;" oninput="updateFmParam('bend',this.value)"><span
id="fmBendVal" style="color:#F0F;font-size:7px;width:20px;">12</span></label>
<label style="color:#888;font-size:7px;">MOD <input type="range" id="fmModIndex" min="1" max="20"
value="8" style="width:40px;height:4px;" oninput="updateFmParam('mod',this.value)"><span id="fmModVal"
style="color:#F0F;font-size:7px;width:16px;">8</span></label>
<label style="color:#888;font-size:7px;">VIB <input type="range" id="fmVibrato" min="0" max="100"
value="40" style="width:40px;height:4px;" oninput="updateFmParam('vib',this.value)"><span
id="fmVibVal" style="color:#F0F;font-size:7px;width:20px;">40</span></label>
</div>
</div>
<!-- FM Pad controls -->
<div id="leadConfig-fmPad" class="lead-config" style="display:none;min-height:36px;">
<div style="color:#88F;font-size:8px;margin-bottom:4px;">FM PAD / AMBIENT</div>
<div style="display:flex;gap:8px;flex-wrap:wrap;align-items:center;">
<label style="color:#FFF;font-size:7px;">GAIN <input type="range" id="padGain" min="0" max="200"
value="100" style="width:50px;height:4px;" oninput="updatePadParam('gain',this.value)"><span
id="padGainVal" style="color:#88F;font-size:7px;width:24px;">100%</span></label>
<label style="color:#888;font-size:7px;">ATTACK <input type="range" id="padAttack" min="10" max="500"
value="100" style="width:50px;height:4px;" oninput="updatePadParam('atk',this.value)"><span
id="padAtkVal" style="color:#88F;font-size:7px;width:28px;">100</span></label>
<label style="color:#888;font-size:7px;">RELEASE <input type="range" id="padRelease" min="100" max="2000"
value="800" style="width:50px;height:4px;" oninput="updatePadParam('rel',this.value)"><span
id="padRelVal" style="color:#88F;font-size:7px;width:28px;">800</span></label>
<label style="color:#888;font-size:7px;">FILTER <input type="range" id="padFilter" min="200" max="4000"
value="1200" style="width:50px;height:4px;" oninput="updatePadParam('flt',this.value)"><span
id="padFltVal" style="color:#88F;font-size:7px;width:28px;">1.2k</span></label>
</div>
</div>
<!-- FM Brass controls -->
<div id="leadConfig-fmBrass" class="lead-config" style="display:none;min-height:36px;">
<div style="color:#FA0;font-size:12px;margin-bottom:4px;">FM BRASS STAB</div>
<div style="display:flex;gap:8px;flex-wrap:wrap;align-items:center;">
<label style="color:#FFF;font-size:7px;">GAIN <input type="range" id="brassGain" min="0" max="200"
value="100" style="width:50px;height:4px;" oninput="updateBrassParam('gain',this.value)"><span
id="brassGainVal" style="color:#FA0;font-size:7px;width:24px;">100%</span></label>
<label style="color:#888;font-size:7px;">ATK <input type="range" id="brassAttack" min="5" max="100"
value="20" style="width:40px;height:4px;" oninput="updateBrassParam('atk',this.value)"><span
id="brassAtkVal" style="color:#FA0;font-size:7px;width:20px;">20</span></label>
<label style="color:#888;font-size:7px;">BRIGHT <input type="range" id="brassBright" min="1" max="20"
value="10" style="width:40px;height:4px;" oninput="updateBrassParam('bright',this.value)"><span
id="brassBrightVal" style="color:#FA0;font-size:7px;width:16px;">10</span></label>
<label style="color:#888;font-size:7px;">DEC <input type="range" id="brassDecay" min="50" max="500"
value="180" style="width:40px;height:4px;" oninput="updateBrassParam('dec',this.value)"><span
id="brassDecVal" style="color:#FA0;font-size:7px;width:28px;">180</span></label>
</div>
</div>
<!-- XPoly controls -->
<div id="leadConfig-xpoly" class="lead-config" style="display:none;min-height:36px;">
<div style="color:#A0F;font-size:8px;margin-bottom:4px;">ATARI POKEY POLY</div>
<div style="display:flex;gap:8px;flex-wrap:wrap;align-items:center;">
<label style="color:#FFF;font-size:7px;">GAIN <input type="range" id="polyGain" min="0" max="200"
value="100" style="width:50px;height:4px;" oninput="updatePolyParam('gain',this.value)"><span
id="polyGainVal" style="color:#A0F;font-size:7px;width:24px;">100%</span></label>
<label style="color:#888;font-size:7px;">DIST <input type="range" id="polyDist" min="0" max="100"
value="60" style="width:40px;height:4px;" oninput="updatePolyParam('dist',this.value)"><span
id="polyDistVal" style="color:#A0F;font-size:7px;width:20px;">60</span></label>
<label style="color:#888;font-size:7px;">DETUNE <input type="range" id="polyDetune" min="0" max="50"
value="15" style="width:40px;height:4px;" oninput="updatePolyParam('detune',this.value)"><span
id="polyDetuneVal" style="color:#A0F;font-size:7px;width:16px;">15</span></label>
<label style="color:#888;font-size:7px;">Q <input type="range" id="polyQ" min="1" max="20" value="8"
style="width:40px;height:4px;" oninput="updatePolyParam('q',this.value)"><span id="polyQVal"
style="color:#A0F;font-size:7px;width:16px;">8</span></label>
</div>
</div>
</div>
<!-- RIGHT: Oscilloscope (50%) -->
<div id="scopePanel"
style="display:inline-block;width:calc(50% - 4px);vertical-align:middle;position:relative;-webkit-user-select:none;user-select:none;-webkit-touch-callout:none;touch-action:none;">
<div style="position:relative;">
<canvas id="scopeCanvas" width="640" height="80"
style="width:100%;height:80px;background:#0a0a0a;border:1px solid #222;border-radius:4px;cursor:crosshair;-webkit-user-select:none;user-select:none;touch-action:none;-webkit-touch-callout:none;"></canvas>
<!-- Scene markers ALWAYS visible at bottom, no overlay blocking -->
<div id="scopeSceneMarkers"
style="position:absolute;bottom:0;left:1px;right:1px;height:12px;display:flex;pointer-events:none;opacity:0.6;">
</div>
<!-- Cursor for dragging -->
<div id="scopeCursor"
style="position:absolute;width:12px;height:100%;background:linear-gradient(90deg,transparent,#F0F,transparent);pointer-events:none;display:none;opacity:0.8;">
</div>
<!-- Settings gear in corner -->
<button onclick="openPerfPadSettings()"
style="position:absolute;top:-20px;right:-16px;background:rgba(0,0,0,0.6);border:1px solid #0FF;color:#0FF;font-size:10px;padding:2px 6px;border-radius:3px;cursor:pointer;opacity:0.7;transition:opacity 0.2s;"
onmouseover="this.style.opacity=1" onmouseout="this.style.opacity=0.7"></button>
</div>
<div style="display:flex;justify-content:space-between;font-size:7px;color:#666;margin-top:3px;">
<span>◀ SCENE ▶</span>
<span id="scopeEffectIndicator" style="color:#F0F;opacity:0;transition:opacity 0.3s;">FX ACTIVE</span>
<span>▲ RES | CUT ▼</span>
</div>
</div>
</div>
<!-- Modal Backdrop -->
<div id="modalBackdrop" onclick="handleBackdropClick()"
style="display:none;position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,0.8);z-index:350;">
</div>
<!-- Performance Pad Settings Modal -->
<div id="perfPadSettingsModal" class="scanlines"
style="display:none;position:fixed;top:50%;left:50%;transform:translate(-50%,-50%);background:#111;border:2px solid #0FF;padding:12px;border-radius:6px;z-index:400;min-width:280px;box-shadow:0 0 20px rgba(0,255,255,0.3);">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:10px;">
<span style="color:#0FF;font-size:11px;font-weight:bold;">PERF PAD SETTINGS</span>
<button onclick="closePerfPadSettings()"
style="background:none;border:none;color:#666;cursor:pointer;font-size:16px;"></button>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:8px;margin-bottom:10px;">
<div>
<label style="color:#888;font-size:7px;display:block;margin-bottom:2px;">FILTER MIN (Hz)</label>
<input type="number" id="ppFilterMin" min="20" max="2000" value="80"
style="width:100%;background:#222;border:1px solid #444;color:#0FF;font-size:10px;padding:4px;text-align:center;">
</div>
<div>
<label style="color:#888;font-size:7px;display:block;margin-bottom:2px;">FILTER MAX (Hz)</label>
<input type="number" id="ppFilterMax" min="1000" max="20000" value="20000"
style="width:100%;background:#222;border:1px solid #444;color:#0FF;font-size:10px;padding:4px;text-align:center;">
</div>
<div>
<label style="color:#888;font-size:7px;display:block;margin-bottom:2px;">RESONANCE MIN</label>
<input type="number" id="ppResMin" min="0.5" max="5" step="0.5" value="1"
style="width:100%;background:#222;border:1px solid #444;color:#0FF;font-size:10px;padding:4px;text-align:center;">
</div>
<div>
<label style="color:#888;font-size:7px;display:block;margin-bottom:2px;">RESONANCE MAX</label>
<input type="number" id="ppResMax" min="1" max="20" step="1" value="13"
style="width:100%;background:#222;border:1px solid #444;color:#0FF;font-size:10px;padding:4px;text-align:center;">
</div>
<div>
<label style="color:#888;font-size:7px;display:block;margin-bottom:2px;">DELAY TIME (ms)</label>
<input type="number" id="ppDelayTime" min="50" max="1000" step="10" value="200"
style="width:100%;background:#222;border:1px solid #444;color:#0FF;font-size:10px;padding:4px;text-align:center;">
</div>
<div>
<label style="color:#888;font-size:7px;display:block;margin-bottom:2px;">VELOCITY SENS</label>
<input type="range" id="ppVelocitySens" min="0" max="100" value="50" style="width:100%;height:4px;"
oninput="document.getElementById('ppVelSensVal').textContent=this.value+'%'">
<span id="ppVelSensVal" style="color:#0FF;font-size:7px;">50%</span>
</div>
<div style="grid-column:span 2;">
<label style="color:#888;font-size:7px;display:block;margin-bottom:2px;">MASTER GAIN</label>
<input type="range" id="ppMasterGain" min="0" max="200" value="100" style="width:100%;height:4px;"
oninput="document.getElementById('ppGainVal').textContent=this.value+'%';applyPerfPadGain()">
<span id="ppGainVal" style="color:#0FF;font-size:7px;">100%</span>
</div>
</div>
<div style="border-top:1px solid #333;padding-top:8px;margin-bottom:8px;">
<div style="color:#888;font-size:12px;margin-bottom:6px;">MORPH MODE</div>
<div style="display:flex;gap:8px;">
<label style="color:#666;font-size:8px;cursor:pointer;">
<input type="radio" name="ppMorphMode" value="ghost" checked style="margin-right:4px;">GHOST NOTES
</label>
<label style="color:#666;font-size:8px;cursor:pointer;">
<input type="radio" name="ppMorphMode" value="real" style="margin-right:4px;">REAL STEPS
</label>
</div>
</div>
<div style="border-top:1px solid #333;padding-top:8px;margin-bottom:8px;">
<div style="color:#888;font-size:12px;margin-bottom:6px;">EFFECTS</div>
<div style="display:flex;gap:12px;flex-wrap:wrap;">
<label style="color:#666;font-size:8px;cursor:pointer;">
<input type="checkbox" id="ppEnableDelay" checked style="margin-right:4px;">DELAY
</label>
<label style="color:#666;font-size:8px;cursor:pointer;">
<input type="checkbox" id="ppEnableSat" checked style="margin-right:4px;">SATURATION
</label>
<label style="color:#666;font-size:8px;cursor:pointer;">
<input type="checkbox" id="ppEnableCrush" checked style="margin-right:4px;">LO-FI
</label>
</div>
</div>
<div style="display:flex;gap:8px;justify-content:flex-end;">
<button onclick="resetPerfPadSettings()"
style="padding:4px 10px;background:#222;border:1px solid #666;color:#888;font-size:8px;cursor:pointer;">RESET</button>
<button onclick="applyPerfPadSettings();closePerfPadSettings()"
style="padding:4px 10px;background:#0AA;border:1px solid #0FF;color:#000;font-size:8px;cursor:pointer;font-weight:bold;">APPLY</button>
</div>
</div>
<!-- Lead Voice Deep Settings Modal -->
<div id="leadVoiceSettingsModal" class="scanlines"
style="display:none;position:fixed;top:50%;left:50%;transform:translate(-50%,-50%);background:#111;border:2px solid #FA0;padding:12px;border-radius:6px;z-index:400;min-width:340px;max-height:80vh;overflow-y:auto;box-shadow:0 0 20px rgba(255,170,0,0.3);">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:10px;">
<span style="color:#FA0;font-size:11px;font-weight:bold;">LEAD VOICE DEEP SETTINGS</span>
<button onclick="closeLeadVoiceSettings()"
style="background:none;border:none;color:#666;cursor:pointer;font-size:16px;"></button>
</div>
<!-- Apply to voices checkboxes -->
<div style="margin-bottom:12px;padding:8px;background:#0a0a0a;border:1px solid #333;border-radius:4px;">
<div style="color:#888;font-size:12px;margin-bottom:6px;">APPLY TO LEAD VOICES:</div>
<div style="display:flex;flex-wrap:wrap;gap:6px;">
<label style="color:#9BBC0F;font-size:12px;cursor:pointer;"><input type="checkbox" id="lvApplyXwave"
checked style="margin-right:2px;">XWAVE</label>
<label style="color:#70A4B2;font-size:12px;cursor:pointer;"><input type="checkbox" id="lvApplySid" checked
style="margin-right:2px;">SID</label>
<label style="color:#0FF;font-size:12px;cursor:pointer;"><input type="checkbox" id="lvApplyChip" checked
style="margin-right:2px;">CHIP</label>
<label style="color:#F0F;font-size:12px;cursor:pointer;"><input type="checkbox" id="lvApplyFm" checked
style="margin-right:2px;">FM WHAM</label>
<label style="color:#FA0;font-size:12px;cursor:pointer;"><input type="checkbox" id="lvApplyBrass" checked
style="margin-right:2px;">BRASS</label>
<label style="color:#A0F;font-size:12px;cursor:pointer;"><input type="checkbox" id="lvApplyPoly" checked
style="margin-right:2px;">POLY</label>
</div>
</div>
<!-- ADSR Envelope - LOCKED BY DEFAULT -->
<div data-category="envelope" data-locked="true" style="margin-bottom:10px;">
<div
style="color:#FA0;font-size:12px;margin-bottom:4px;display:flex;justify-content:space-between;align-items:center;">
ENVELOPE (ADSR) <button onclick="toggleCategoryLock(this)"
style="padding:1px 4px;background:#222;border:1px solid #F44;color:#F44;font-size:8px;cursor:pointer;">🔒</button>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr 1fr 1fr;gap:6px;">
<div>
<label style="color:#888;font-size:12px;display:block;">ATTACK</label>
<input type="range" id="lvAttack" min="1" max="500" value="10"
style="width:100%;height:4px;opacity:0.5;"
oninput="document.getElementById('lvAtkVal').textContent=this.value+'ms';updateDeepSetting('attack',this.value)">
<span id="lvAtkVal" style="color:#FA0;font-size:16px;">10ms</span>
</div>
<div>
<label style="color:#888;font-size:12px;display:block;">DECAY</label>
<input type="range" id="lvDecay" min="10" max="1000" value="100"
style="width:100%;height:4px;opacity:0.5;"
oninput="document.getElementById('lvDecVal').textContent=this.value+'ms';updateDeepSetting('decay',this.value)">
<span id="lvDecVal" style="color:#FA0;font-size:16px;">100ms</span>
</div>
<div>
<label style="color:#888;font-size:12px;display:block;">SUSTAIN</label>
<input type="range" id="lvSustain" min="0" max="100" value="70"
style="width:100%;height:4px;opacity:0.5;"
oninput="document.getElementById('lvSusVal').textContent=this.value+'%';updateDeepSetting('sustain',this.value)">
<span id="lvSusVal" style="color:#FA0;font-size:16px;">70%</span>
</div>
<div>
<label style="color:#888;font-size:12px;display:block;">RELEASE</label>
<input type="range" id="lvRelease" min="10" max="2000" value="200"
style="width:100%;height:4px;opacity:0.5;"
oninput="document.getElementById('lvRelVal').textContent=this.value+'ms';updateDeepSetting('release',this.value)">
<span id="lvRelVal" style="color:#FA0;font-size:16px;">200ms</span>
</div>
</div>
</div>
<!-- Filter -->
<div data-category="filter" style="margin-bottom:10px;">
<div
style="color:#FA0;font-size:12px;margin-bottom:4px;display:flex;justify-content:space-between;align-items:center;">
FILTER <button onclick="toggleCategoryLock(this)"
style="padding:1px 4px;background:#222;border:1px solid #444;color:#888;font-size:8px;cursor:pointer;">🔓</button>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr 1fr;gap:6px;">
<div>
<label style="color:#888;font-size:12px;display:block;">CUTOFF</label>
<input type="range" id="lvFilterCut" min="100" max="12000" value="4000" style="width:100%;height:4px;"
oninput="document.getElementById('lvCutVal').textContent=(this.value/1000).toFixed(1)+'k';updateDeepSetting('filterCut',this.value)">
<span id="lvCutVal" style="color:#FA0;font-size:16px;">4.0k</span>
</div>
<div>
<label style="color:#888;font-size:12px;display:block;">RESONANCE</label>
<input type="range" id="lvFilterRes" min="0" max="20" value="2" style="width:100%;height:4px;"
oninput="document.getElementById('lvResVal').textContent=this.value;updateDeepSetting('filterRes',this.value)">
<span id="lvResVal" style="color:#FA0;font-size:16px;">2</span>
</div>
<div>
<label style="color:#888;font-size:12px;display:block;">TYPE</label>
<select id="lvFilterType"
style="width:100%;background:#222;border:1px solid #444;color:#FA0;font-size:8px;padding:2px;"
onchange="leadVoiceSettings.filterType=this.value;previewDeepSound()">
<option value="lowpass">LOWPASS</option>
<option value="highpass">HIGHPASS</option>
<option value="bandpass">BANDPASS</option>
</select>
</div>
</div>
</div>
<!-- Vibrato / LFO -->
<div data-category="vibrato" style="margin-bottom:10px;">
<div
style="color:#FA0;font-size:12px;margin-bottom:4px;display:flex;justify-content:space-between;align-items:center;">
VIBRATO / LFO <button onclick="toggleCategoryLock(this)"
style="padding:1px 4px;background:#222;border:1px solid #444;color:#888;font-size:8px;cursor:pointer;">🔓</button>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr 1fr;gap:6px;">
<div>
<label style="color:#888;font-size:12px;display:block;">RATE</label>
<input type="range" id="lvVibRate" min="1" max="20" value="5" style="width:100%;height:4px;"
oninput="document.getElementById('lvVibRateVal').textContent=this.value+'Hz';updateDeepSetting('vibRate',this.value)">
<span id="lvVibRateVal" style="color:#FA0;font-size:16px;">5Hz</span>
</div>
<div>
<label style="color:#888;font-size:12px;display:block;">DEPTH</label>
<input type="range" id="lvVibDepth" min="0" max="100" value="20" style="width:100%;height:4px;"
oninput="document.getElementById('lvVibDepthVal').textContent=this.value+'%';updateDeepSetting('vibDepth',this.value)">
<span id="lvVibDepthVal" style="color:#FA0;font-size:16px;">20%</span>
</div>
<div>
<label style="color:#888;font-size:12px;display:block;">DELAY</label>
<input type="range" id="lvVibDelay" min="0" max="500" value="50" style="width:100%;height:4px;"
oninput="document.getElementById('lvVibDelayVal').textContent=this.value+'ms';updateDeepSetting('vibDelay',this.value)">
<span id="lvVibDelayVal" style="color:#FA0;font-size:16px;">50ms</span>
</div>
</div>
</div>
<!-- Output / Normalization -->
<div style="margin-bottom:10px;">
<div style="color:#FA0;font-size:12px;margin-bottom:4px;">OUTPUT</div>
<div style="display:grid;grid-template-columns:1fr 1fr 1fr;gap:6px;">
<div>
<label style="color:#888;font-size:12px;display:block;">VOLUME</label>
<input type="range" id="lvVolume" min="0" max="200" value="100" style="width:100%;height:4px;"
oninput="document.getElementById('lvVolVal').textContent=this.value+'%';updateDeepSetting('volume',this.value)">
<span id="lvVolVal" style="color:#FA0;font-size:16px;">100%</span>
</div>
<div>
<label style="color:#888;font-size:12px;display:block;">PAN</label>
<input type="range" id="lvPan" min="-100" max="100" value="0" style="width:100%;height:4px;"
oninput="document.getElementById('lvPanVal').textContent=(this.value>0?'R':'L')+Math.abs(this.value);updateDeepSetting('pan',this.value)">
<span id="lvPanVal" style="color:#FA0;font-size:16px;">C</span>
</div>
<div style="display:flex;flex-direction:column;justify-content:center;">
<label style="color:#888;font-size:12px;cursor:pointer;"><input type="checkbox" id="lvNormalize" checked
style="margin-right:2px;">NORMALIZE</label>
</div>
</div>
</div>
<!-- Pitch -->
<div data-category="pitch" style="margin-bottom:10px;">
<div
style="color:#FA0;font-size:12px;margin-bottom:4px;display:flex;justify-content:space-between;align-items:center;">
PITCH <button onclick="toggleCategoryLock(this)"
style="padding:1px 4px;background:#222;border:1px solid #444;color:#888;font-size:8px;cursor:pointer;">🔓</button>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr 1fr 1fr;gap:6px;">
<div>
<label style="color:#888;font-size:12px;display:block;">OCTAVE</label>
<input type="range" id="lvOctave" min="-3" max="3" step="0.05" value="0" style="width:100%;height:4px;"
oninput="const v=parseFloat(this.value);const s=v>=0?'+':'';document.getElementById('lvOctVal').textContent=s+v.toFixed(2);updateDeepSetting('octave',v,true)">
<span id="lvOctVal" style="color:#FA0;font-size:14px;">+0.00</span>
</div>
<div>
<label style="color:#888;font-size:12px;display:block;">DETUNE</label>
<input type="range" id="lvDetune" min="-100" max="100" step="1" value="0" style="width:100%;height:4px;"
oninput="document.getElementById('lvDetuneVal').textContent=this.value+'c';updateDeepSetting('detune',this.value)">
<span id="lvDetuneVal" style="color:#FA0;font-size:14px;">0c</span>
</div>
<div>
<label style="color:#888;font-size:12px;display:block;">DRIFT</label>
<input type="range" id="lvDrift" min="0" max="25" step="0.5" value="0" style="width:100%;height:4px;"
oninput="document.getElementById('lvDriftVal').textContent=this.value+'c';updateDeepSetting('drift',this.value,true)">
<span id="lvDriftVal" style="color:#F0A;font-size:14px;">0c</span>
</div>
<div>
<label style="color:#888;font-size:12px;display:block;">GLIDE</label>
<input type="range" id="lvGlide" min="0" max="200" value="0" style="width:100%;height:4px;"
oninput="document.getElementById('lvGlideVal').textContent=this.value+'ms';updateDeepSetting('glide',this.value)">
<span id="lvGlideVal" style="color:#FA0;font-size:14px;">0ms</span>
</div>
</div>
</div>
<!-- Modulation -->
<div data-category="modulation" style="margin-bottom:10px;">
<div
style="color:#FA0;font-size:12px;margin-bottom:4px;display:flex;justify-content:space-between;align-items:center;">
MODULATION <button onclick="toggleCategoryLock(this)"
style="padding:1px 4px;background:#222;border:1px solid #444;color:#888;font-size:8px;cursor:pointer;">🔓</button>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr 1fr;gap:6px;">
<div>
<label style="color:#888;font-size:12px;display:block;">LFO→FILTER</label>
<input type="range" id="lvLfoFilter" min="0" max="100" value="0" style="width:100%;height:4px;"
oninput="document.getElementById('lvLfoFilterVal').textContent=this.value+'%';updateDeepSetting('lfoFilter',this.value)">
<span id="lvLfoFilterVal" style="color:#0FF;font-size:16px;">0%</span>
</div>
<div>
<label style="color:#888;font-size:12px;display:block;">LFO RATE</label>
<input type="range" id="lvLfoRate" min="1" max="20" value="4" step="0.5" style="width:100%;height:4px;"
oninput="document.getElementById('lvLfoRateVal').textContent=this.value+'Hz';updateDeepSetting('lfoRate',this.value,true)">
<span id="lvLfoRateVal" style="color:#0FF;font-size:16px;">4Hz</span>
</div>
<div>
<label style="color:#888;font-size:12px;display:block;">STEREO W</label>
<input type="range" id="lvStereoWidth" min="0" max="100" value="0" style="width:100%;height:4px;"
oninput="document.getElementById('lvStereoVal').textContent=this.value+'%';updateDeepSetting('stereoWidth',this.value)">
<span id="lvStereoVal" style="color:#F0F;font-size:16px;">0%</span>
</div>
</div>
</div>
<!-- FX Sends -->
<div data-category="fx" style="margin-bottom:10px;">
<div
style="color:#FA0;font-size:12px;margin-bottom:4px;display:flex;justify-content:space-between;align-items:center;">
FX SENDS <button onclick="toggleCategoryLock(this)"
style="padding:1px 4px;background:#222;border:1px solid #444;color:#888;font-size:8px;cursor:pointer;">🔓</button>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr 1fr 1fr;gap:6px;">
<div>
<label style="color:#888;font-size:12px;display:block;">DELAY</label>
<input type="range" id="lvDelaySend" min="0" max="100" value="0" style="width:100%;height:4px;"
oninput="document.getElementById('lvDelayVal').textContent=this.value+'%';updateDeepSetting('delaySend',this.value)">
<span id="lvDelayVal" style="color:#0FF;font-size:16px;">0%</span>
</div>
<div>
<label style="color:#888;font-size:12px;display:block;">REVERB</label>
<input type="range" id="lvReverbSend" min="0" max="100" value="0" style="width:100%;height:4px;"
oninput="document.getElementById('lvReverbVal').textContent=this.value+'%';updateDeepSetting('reverbSend',this.value)">
<span id="lvReverbVal" style="color:#88F;font-size:16px;">0%</span>
</div>
<div>
<label style="color:#888;font-size:12px;display:block;">DRIVE</label>
<input type="range" id="lvDrive" min="0" max="100" value="0" style="width:100%;height:4px;"
oninput="document.getElementById('lvDriveVal').textContent=this.value+'%';updateDeepSetting('drive',this.value)">
<span id="lvDriveVal" style="color:#F80;font-size:16px;">0%</span>
</div>
<div>
<label style="color:#888;font-size:12px;display:block;">CHORUS</label>
<input type="range" id="lvChorus" min="0" max="100" value="0" style="width:100%;height:4px;"
oninput="document.getElementById('lvChorusVal').textContent=this.value+'%';updateDeepSetting('chorus',this.value)">
<span id="lvChorusVal" style="color:#F0F;font-size:16px;">0%</span>
</div>
</div>
</div>
<!-- Lo-Fi -->
<div data-category="lofi" style="margin-bottom:12px;">
<div
style="color:#FA0;font-size:12px;margin-bottom:4px;display:flex;justify-content:space-between;align-items:center;">
LO-FI <button onclick="toggleCategoryLock(this)"
style="padding:1px 4px;background:#222;border:1px solid #444;color:#888;font-size:8px;cursor:pointer;">🔓</button>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:6px;">
<div>
<label style="color:#888;font-size:12px;display:block;">BIT CRUSH</label>
<input type="range" id="lvBitCrush" min="0" max="100" value="0" style="width:100%;height:4px;"
oninput="document.getElementById('lvBitVal').textContent=this.value+'%';updateDeepSetting('bitCrush',this.value)">
<span id="lvBitVal" style="color:#9BC;font-size:16px;">0%</span>
</div>
<div>
<label style="color:#888;font-size:12px;display:block;">SAMPLE RATE</label>
<input type="range" id="lvSampleRate" min="0" max="100" value="0" style="width:100%;height:4px;"
oninput="document.getElementById('lvSrVal').textContent=this.value+'%';updateDeepSetting('sampleRate',this.value)">
<span id="lvSrVal" style="color:#9BC;font-size:16px;">0%</span>
</div>
</div>
</div>
<div style="display:flex;gap:8px;justify-content:space-between;align-items:center;">
<div style="display:flex;gap:2px;align-items:center;">
<button onclick="walkDeepSettings(-1)"
style="padding:4px 6px;background:#333;border:1px solid #0FF;color:#0FF;font-size:10px;cursor:pointer;"
title="Nudge all params down"></button>
<button onclick="walkDeepSettings(0)"
style="padding:4px 8px;background:#222;border:1px solid #0FF;color:#0FF;font-size:8px;cursor:pointer;"
title="Random walk all params">WALK</button>
<button onclick="walkDeepSettings(1)"
style="padding:4px 6px;background:#333;border:1px solid #0FF;color:#0FF;font-size:10px;cursor:pointer;"
title="Nudge all params up"></button>
</div>
<div style="display:flex;gap:8px;">
<button onclick="resetLeadVoiceSettings()"
style="padding:4px 10px;background:#222;border:1px solid #666;color:#888;font-size:8px;cursor:pointer;">RESET</button>
<button onclick="applyLeadVoiceSettings();closeLeadVoiceSettings()"
style="padding:4px 10px;background:#A80;border:1px solid #FA0;color:#000;font-size:8px;cursor:pointer;font-weight:bold;">APPLY</button>
</div>
</div>
</div>
<!-- Kick Deep Settings Modal -->
<div id="kickDeepSettingsModal" class="scanlines"
style="display:none;position:fixed;top:50%;left:50%;transform:translate(-50%,-50%);background:#111;border:2px solid #F44;padding:12px;border-radius:6px;z-index:400;min-width:280px;max-height:80vh;overflow-y:auto;box-shadow:0 0 20px rgba(255,68,68,0.3);">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:10px;">
<span style="color:#F44;font-size:11px;font-weight:bold;">🥁 KICK DEEP SETTINGS</span>
<button onclick="closeKickDeepSettings()"
style="background:none;border:none;color:#666;cursor:pointer;font-size:16px;"></button>
</div>
<div style="margin-bottom:10px;">
<label style="color:#888;font-size:10px;display:block;margin-bottom:4px;">PITCH</label>
<input type="range" id="kickDeepPitch" min="30" max="100" value="55" style="width:100%;accent-color:#F44;"
oninput="kickDeepSettings.pitch=+this.value;document.getElementById('kickDeepPitchVal').textContent=this.value+'Hz'">
<span id="kickDeepPitchVal" style="color:#F44;font-size:10px;">55Hz</span>
</div>
<div style="margin-bottom:10px;">
<label style="color:#888;font-size:10px;display:block;margin-bottom:4px;">DECAY</label>
<input type="range" id="kickDeepDecay" min="50" max="500" value="150" style="width:100%;accent-color:#F44;"
oninput="kickDeepSettings.decay=+this.value;document.getElementById('kickDeepDecayVal').textContent=this.value+'ms'">
<span id="kickDeepDecayVal" style="color:#F44;font-size:10px;">150ms</span>
</div>
<div style="margin-bottom:10px;">
<label style="color:#888;font-size:10px;display:block;margin-bottom:4px;">PUNCH</label>
<input type="range" id="kickDeepPunch" min="0" max="100" value="50" style="width:100%;accent-color:#F44;"
oninput="kickDeepSettings.punch=+this.value;document.getElementById('kickDeepPunchVal').textContent=this.value+'%'">
<span id="kickDeepPunchVal" style="color:#F44;font-size:10px;">50%</span>
</div>
<div style="margin-bottom:10px;">
<label style="color:#888;font-size:10px;display:block;margin-bottom:4px;">DRIVE</label>
<input type="range" id="kickDeepDrive" min="0" max="100" value="20" style="width:100%;accent-color:#F44;"
oninput="kickDeepSettings.drive=+this.value;document.getElementById('kickDeepDriveVal').textContent=this.value+'%'">
<span id="kickDeepDriveVal" style="color:#F44;font-size:10px;">20%</span>
</div>
<div style="display:flex;justify-content:space-between;gap:8px;margin-top:12px;">
<button onclick="resetKickDeepSettings()" style="padding:4px 10px;background:#222;border:1px solid #666;color:#888;font-size:8px;cursor:pointer;">RESET</button>
<button onclick="closeKickDeepSettings()" style="padding:4px 10px;background:#A44;border:1px solid #F44;color:#000;font-size:8px;cursor:pointer;font-weight:bold;">CLOSE</button>
</div>
</div>
<!-- Snare Deep Settings Modal -->
<div id="snareDeepSettingsModal" class="scanlines"
style="display:none;position:fixed;top:50%;left:50%;transform:translate(-50%,-50%);background:#111;border:2px solid #FA4;padding:12px;border-radius:6px;z-index:400;min-width:280px;max-height:80vh;overflow-y:auto;box-shadow:0 0 20px rgba(255,170,68,0.3);">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:10px;">
<span style="color:#FA4;font-size:11px;font-weight:bold;">🥁 SNARE DEEP SETTINGS</span>
<button onclick="closeSnareDeepSettings()"
style="background:none;border:none;color:#666;cursor:pointer;font-size:16px;"></button>
</div>
<div style="margin-bottom:10px;">
<label style="color:#888;font-size:10px;display:block;margin-bottom:4px;">TONE</label>
<input type="range" id="snareDeepTone" min="100" max="400" value="200" style="width:100%;accent-color:#FA4;"
oninput="snareDeepSettings.tone=+this.value;document.getElementById('snareDeepToneVal').textContent=this.value+'Hz'">
<span id="snareDeepToneVal" style="color:#FA4;font-size:10px;">200Hz</span>
</div>
<div style="margin-bottom:10px;">
<label style="color:#888;font-size:10px;display:block;margin-bottom:4px;">SNAPPY</label>
<input type="range" id="snareDeepSnappy" min="0" max="100" value="60" style="width:100%;accent-color:#FA4;"
oninput="snareDeepSettings.snappy=+this.value;document.getElementById('snareDeepSnappyVal').textContent=this.value+'%'">
<span id="snareDeepSnappyVal" style="color:#FA4;font-size:10px;">60%</span>
</div>
<div style="margin-bottom:10px;">
<label style="color:#888;font-size:10px;display:block;margin-bottom:4px;">DECAY</label>
<input type="range" id="snareDeepDecay" min="50" max="400" value="150" style="width:100%;accent-color:#FA4;"
oninput="snareDeepSettings.decay=+this.value;document.getElementById('snareDeepDecayVal').textContent=this.value+'ms'">
<span id="snareDeepDecayVal" style="color:#FA4;font-size:10px;">150ms</span>
</div>
<div style="margin-bottom:10px;">
<label style="color:#888;font-size:10px;display:block;margin-bottom:4px;">NOISE MIX</label>
<input type="range" id="snareDeepNoise" min="0" max="100" value="70" style="width:100%;accent-color:#FA4;"
oninput="snareDeepSettings.noise=+this.value;document.getElementById('snareDeepNoiseVal').textContent=this.value+'%'">
<span id="snareDeepNoiseVal" style="color:#FA4;font-size:10px;">70%</span>
</div>
<div style="display:flex;justify-content:space-between;gap:8px;margin-top:12px;">
<button onclick="resetSnareDeepSettings()" style="padding:4px 10px;background:#222;border:1px solid #666;color:#888;font-size:8px;cursor:pointer;">RESET</button>
<button onclick="closeSnareDeepSettings()" style="padding:4px 10px;background:#A84;border:1px solid #FA4;color:#000;font-size:8px;cursor:pointer;font-weight:bold;">CLOSE</button>
</div>
</div>
<!-- Hihat Deep Settings Modal -->
<div id="hihatDeepSettingsModal" class="scanlines"
style="display:none;position:fixed;top:50%;left:50%;transform:translate(-50%,-50%);background:#111;border:2px solid #FF4;padding:12px;border-radius:6px;z-index:400;min-width:280px;max-height:80vh;overflow-y:auto;box-shadow:0 0 20px rgba(255,255,68,0.3);">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:10px;">
<span style="color:#FF4;font-size:11px;font-weight:bold;">🎹 HIHAT DEEP SETTINGS</span>
<button onclick="closeHihatDeepSettings()"
style="background:none;border:none;color:#666;cursor:pointer;font-size:16px;"></button>
</div>
<div style="margin-bottom:10px;">
<label style="color:#888;font-size:10px;display:block;margin-bottom:4px;">CLOSED DECAY</label>
<input type="range" id="hihatDeepClosed" min="10" max="150" value="50" style="width:100%;accent-color:#FF4;"
oninput="hihatDeepSettings.closed=+this.value;document.getElementById('hihatDeepClosedVal').textContent=this.value+'ms'">
<span id="hihatDeepClosedVal" style="color:#FF4;font-size:10px;">50ms</span>
</div>
<div style="margin-bottom:10px;">
<label style="color:#888;font-size:10px;display:block;margin-bottom:4px;">OPEN DECAY</label>
<input type="range" id="hihatDeepOpen" min="100" max="500" value="200" style="width:100%;accent-color:#FF4;"
oninput="hihatDeepSettings.open=+this.value;document.getElementById('hihatDeepOpenVal').textContent=this.value+'ms'">
<span id="hihatDeepOpenVal" style="color:#FF4;font-size:10px;">200ms</span>
</div>
<div style="margin-bottom:10px;">
<label style="color:#888;font-size:10px;display:block;margin-bottom:4px;">TONE</label>
<input type="range" id="hihatDeepTone" min="4000" max="12000" value="8000" style="width:100%;accent-color:#FF4;"
oninput="hihatDeepSettings.tone=+this.value;document.getElementById('hihatDeepToneVal').textContent=(this.value/1000).toFixed(1)+'kHz'">
<span id="hihatDeepToneVal" style="color:#FF4;font-size:10px;">8.0kHz</span>
</div>
<div style="margin-bottom:10px;">
<label style="color:#888;font-size:10px;display:block;margin-bottom:4px;">RING</label>
<input type="range" id="hihatDeepRing" min="0" max="100" value="30" style="width:100%;accent-color:#FF4;"
oninput="hihatDeepSettings.ring=+this.value;document.getElementById('hihatDeepRingVal').textContent=this.value+'%'">
<span id="hihatDeepRingVal" style="color:#FF4;font-size:10px;">30%</span>
</div>
<div style="display:flex;justify-content:space-between;gap:8px;margin-top:12px;">
<button onclick="resetHihatDeepSettings()" style="padding:4px 10px;background:#222;border:1px solid #666;color:#888;font-size:8px;cursor:pointer;">RESET</button>
<button onclick="closeHihatDeepSettings()" style="padding:4px 10px;background:#AA4;border:1px solid #FF4;color:#000;font-size:8px;cursor:pointer;font-weight:bold;">CLOSE</button>
</div>
</div>
<!-- Bass Deep Settings Modal -->
<div id="bassDeepSettingsModal" class="scanlines"
style="display:none;position:fixed;top:50%;left:50%;transform:translate(-50%,-50%);background:#111;border:2px solid #4F4;padding:12px;border-radius:6px;z-index:400;min-width:280px;max-height:80vh;overflow-y:auto;box-shadow:0 0 20px rgba(68,255,68,0.3);">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:10px;">
<span style="color:#4F4;font-size:11px;font-weight:bold;">🎸 BASS DEEP SETTINGS</span>
<button onclick="closeBassDeepSettings()"
style="background:none;border:none;color:#666;cursor:pointer;font-size:16px;"></button>
</div>
<div style="margin-bottom:10px;">
<label style="color:#888;font-size:10px;display:block;margin-bottom:4px;">FILTER CUTOFF</label>
<input type="range" id="bassDeepFilter" min="100" max="4000" value="1200" style="width:100%;accent-color:#4F4;"
oninput="bassDeepSettings.filter=+this.value;document.getElementById('bassDeepFilterVal').textContent=(this.value/1000).toFixed(1)+'kHz'">
<span id="bassDeepFilterVal" style="color:#4F4;font-size:10px;">1.2kHz</span>
</div>
<div style="margin-bottom:10px;">
<label style="color:#888;font-size:10px;display:block;margin-bottom:4px;">RESONANCE</label>
<input type="range" id="bassDeepRes" min="0" max="20" value="5" style="width:100%;accent-color:#4F4;"
oninput="bassDeepSettings.res=+this.value;document.getElementById('bassDeepResVal').textContent=this.value">
<span id="bassDeepResVal" style="color:#4F4;font-size:10px;">5</span>
</div>
<div style="margin-bottom:10px;">
<label style="color:#888;font-size:10px;display:block;margin-bottom:4px;">SLIDE</label>
<input type="range" id="bassDeepSlide" min="0" max="100" value="30" style="width:100%;accent-color:#4F4;"
oninput="bassDeepSettings.slide=+this.value;document.getElementById('bassDeepSlideVal').textContent=this.value+'%'">
<span id="bassDeepSlideVal" style="color:#4F4;font-size:10px;">30%</span>
</div>
<div style="margin-bottom:10px;">
<label style="color:#888;font-size:10px;display:block;margin-bottom:4px;">DECAY</label>
<input type="range" id="bassDeepDecay" min="100" max="1000" value="400" style="width:100%;accent-color:#4F4;"
oninput="bassDeepSettings.decay=+this.value;document.getElementById('bassDeepDecayVal').textContent=this.value+'ms'">
<span id="bassDeepDecayVal" style="color:#4F4;font-size:10px;">400ms</span>
</div>
<div style="display:flex;justify-content:space-between;gap:8px;margin-top:12px;">
<button onclick="resetBassDeepSettings()" style="padding:4px 10px;background:#222;border:1px solid #666;color:#888;font-size:8px;cursor:pointer;">RESET</button>
<button onclick="closeBassDeepSettings()" style="padding:4px 10px;background:#4A4;border:1px solid #4F4;color:#000;font-size:8px;cursor:pointer;font-weight:bold;">CLOSE</button>
</div>
</div>
<!-- Pad Deep Settings Modal -->
<div id="padDeepSettingsModal" class="scanlines"
style="display:none;position:fixed;top:50%;left:50%;transform:translate(-50%,-50%);background:#111;border:2px solid #4FF;padding:12px;border-radius:6px;z-index:400;min-width:280px;max-height:80vh;overflow-y:auto;box-shadow:0 0 20px rgba(68,255,255,0.3);">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:10px;">
<span style="color:#4FF;font-size:11px;font-weight:bold;">🎹 PAD DEEP SETTINGS</span>
<button onclick="closePadDeepSettings()"
style="background:none;border:none;color:#666;cursor:pointer;font-size:16px;"></button>
</div>
<div style="margin-bottom:10px;">
<label style="color:#888;font-size:10px;display:block;margin-bottom:4px;">ATTACK</label>
<input type="range" id="padDeepAttack" min="10" max="2000" value="200" style="width:100%;accent-color:#4FF;"
oninput="padDeepSettings.attack=+this.value;document.getElementById('padDeepAttackVal').textContent=this.value+'ms'">
<span id="padDeepAttackVal" style="color:#4FF;font-size:10px;">200ms</span>
</div>
<div style="margin-bottom:10px;">
<label style="color:#888;font-size:10px;display:block;margin-bottom:4px;">RELEASE</label>
<input type="range" id="padDeepRelease" min="100" max="3000" value="800" style="width:100%;accent-color:#4FF;"
oninput="padDeepSettings.release=+this.value;document.getElementById('padDeepReleaseVal').textContent=this.value+'ms'">
<span id="padDeepReleaseVal" style="color:#4FF;font-size:10px;">800ms</span>
</div>
<div style="margin-bottom:10px;">
<label style="color:#888;font-size:10px;display:block;margin-bottom:4px;">FILTER</label>
<input type="range" id="padDeepFilter" min="200" max="8000" value="2000" style="width:100%;accent-color:#4FF;"
oninput="padDeepSettings.filter=+this.value;document.getElementById('padDeepFilterVal').textContent=(this.value/1000).toFixed(1)+'kHz'">
<span id="padDeepFilterVal" style="color:#4FF;font-size:10px;">2.0kHz</span>
</div>
<div style="margin-bottom:10px;">
<label style="color:#888;font-size:10px;display:block;margin-bottom:4px;">DETUNE</label>
<input type="range" id="padDeepDetune" min="0" max="50" value="10" style="width:100%;accent-color:#4FF;"
oninput="padDeepSettings.detune=+this.value;document.getElementById('padDeepDetuneVal').textContent=this.value+'¢'">
<span id="padDeepDetuneVal" style="color:#4FF;font-size:10px;">10¢</span>
</div>
<div style="margin-bottom:10px;">
<label style="color:#888;font-size:10px;display:block;margin-bottom:4px;">VOICES</label>
<input type="range" id="padDeepVoices" min="1" max="8" value="4" style="width:100%;accent-color:#4FF;"
oninput="padDeepSettings.voices=+this.value;document.getElementById('padDeepVoicesVal').textContent=this.value">
<span id="padDeepVoicesVal" style="color:#4FF;font-size:10px;">4</span>
</div>
<div style="display:flex;justify-content:space-between;gap:8px;margin-top:12px;">
<button onclick="resetPadDeepSettings()" style="padding:4px 10px;background:#222;border:1px solid #666;color:#888;font-size:8px;cursor:pointer;">RESET</button>
<button onclick="closePadDeepSettings()" style="padding:4px 10px;background:#4AA;border:1px solid #4FF;color:#000;font-size:8px;cursor:pointer;font-weight:bold;">CLOSE</button>
</div>
</div>
<!-- P-Lock Modal - Compact with number inputs -->
<div id="seqPlockModal"
style="display:none;position:fixed;background:#111;border:2px solid #F0F;padding:8px;border-radius:4px;z-index:400;min-width:180px;">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:6px;">
<span id="plockTitle" style="color:#F0F;font-size:10px;">STEP 1 - KICK</span>
<button onclick="closePlockModal()"
style="background:none;border:none;color:#666;cursor:pointer;font-size:14px;"></button>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:4px;margin-bottom:6px;">
<div style="display:flex;align-items:center;gap:4px;">
<label style="color:#888;font-size:8px;width:28px;">VEL</label>
<input type="number" id="plockVel" min="0" max="100" value="80"
style="width:40px;background:#222;border:1px solid #444;color:#FFF;font-size:10px;padding:2px;text-align:center;"
oninput="updatePlockRealtime()">
</div>
<div style="display:flex;align-items:center;gap:4px;">
<label style="color:#888;font-size:8px;width:28px;">PROB</label>
<input type="number" id="plockProb" min="0" max="100" value="100"
style="width:40px;background:#222;border:1px solid #444;color:#FFF;font-size:10px;padding:2px;text-align:center;"
oninput="updatePlockRealtime()">
</div>
<div style="display:flex;align-items:center;gap:4px;">
<label style="color:#0FF;font-size:8px;width:28px;">PITCH</label>
<input type="number" id="plockPitch" min="-12" max="12" value="0"
style="width:40px;background:#222;border:1px solid #0AA;color:#0FF;font-size:10px;padding:2px;text-align:center;"
oninput="updatePlockRealtime()">
</div>
<div style="display:flex;align-items:center;gap:4px;">
<label style="color:#0FF;font-size:8px;width:28px;">NUDGE</label>
<input type="number" id="plockNudge" min="-50" max="50" value="0"
style="width:40px;background:#222;border:1px solid #0AA;color:#0FF;font-size:10px;padding:2px;text-align:center;"
oninput="updatePlockRealtime()">
</div>
<div style="display:flex;align-items:center;gap:4px;grid-column:span 2;justify-content:center;">
<label style="color:#F0F;font-size:8px;">RATCHET</label>
<select id="plockRatchet"
style="background:#222;border:1px solid #F0F;color:#F0F;font-size:10px;padding:2px;"
onchange="updatePlockRealtime()">
<option value="1">1x</option>
<option value="2">2x</option>
<option value="3">3x</option>
<option value="4">4x</option>
</select>
</div>
</div>
<div style="display:flex;gap:4px;">
<button onclick="undoPlock()"
style="flex:1;padding:4px;font-size:8px;background:#FA0;color:#000;border:none;cursor:pointer;">UNDO</button>
<button onclick="clearPlock()"
style="flex:1;padding:4px;font-size:8px;background:#333;color:#FFF;border:none;cursor:pointer;">CLR</button>
<button onclick="closePlockModal()"
style="flex:1;padding:4px;font-size:8px;background:#F0F;color:#000;border:none;cursor:pointer;">OK</button>
</div>
</div>
<!-- Bottom row: Log only -->
<div id="seqBottomRow" style="display:flex;gap:8px;flex-wrap:wrap;min-width:320px;align-items:stretch;">
<!-- Mini Evolution Log -->
<div
style="background:#0A0A10;border:1px solid #333;padding:4px 6px;height:60px;overflow-y:auto;flex:1;min-width:200px;">
<div id="seqEvolutionLog"
style="font-family:'IBM Plex Mono',monospace;font-size:9px;color:#0FF;line-height:1.3;"></div>
</div>
<!-- Collapse button for future expansion -->
<button id="seqLogExpandBtn" onclick="toggleSeqLogExpand()"
style="padding:4px 8px;background:#222;border:1px solid #333;color:#666;font-size:8px;cursor:pointer;align-self:flex-end;"
title="More options">MORE</button>
</div>
<!-- Collapsible section for future features -->
<div id="seqLogExpanded"
style="display:none;background:#0A0A10;border:1px solid #333;padding:8px;margin-top:4px;min-width:320px;">
<div style="color:#666;font-size:8px;font-family:'IBM Plex Mono',monospace;">Save/Load, Export, and more
coming soon...</div>
</div>
<!-- Director ($1010 DSL) Panel - HIDDEN: Needs Storybook redesign -->
<div id="directorPanel"
style="display:none;background:#0A0A10;border:1px solid #333;padding:8px;margin-top:8px;min-width:320px;">
<div
style="color:#0f0;font-size:10px;font-family:'IBM Plex Mono',monospace;font-weight:bold;margin-bottom:4px;">
DIRECTOR ($1010)</div>
<textarea id="directorScript" placeholder="# TYPE YOUR $1010 SCRIPT HERE...
@ 0
BPM 120
PATTERN bass x...x...x...x...
@ 4b
BPM 140
VOICE motif fmBrass"
style="width:100%;height:120px;background:#111;color:#0f0;border:1px solid #444;font-family:monospace;font-size:10px;padding:4px;box-sizing:border-box;resize:vertical;"></textarea>
<div style="display:flex;gap:4px;margin-top:4px;">
<button id="runDirectorBtn"
style="flex:1;padding:6px;font-size:10px;background:#0a0;color:#000;border:none;cursor:pointer;font-weight:bold;"
onclick="breakbeat.director.parse(document.getElementById('directorScript').value); breakbeat.director.start();">
RUN</button>
<button id="stopDirectorBtn"
style="flex:1;padding:6px;font-size:10px;background:#a00;color:#fff;border:none;cursor:pointer;font-weight:bold;"
onclick="breakbeat.director.stop();">■ STOP</button>
</div>
<div id="directorStatus"
style="margin-top:4px;font-size:8px;color:#aaa;font-family:monospace;text-align:center;">READY</div>
</div>
</div>
</div>
<!-- Voice Config Modal -->
<div id="voiceConfigOverlay"
style="display:none;position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,0.95);z-index:250;align-items:center;justify-content:center;">
<div id="voiceConfigModal" class="scanlines"
style="position:relative;background:#0A0A12;border:2px solid #FA0;max-width:500px;width:90%;padding:16px;max-height:80vh;overflow-y:auto;">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:12px;">
<h3 style="color:#FA0;font-size:12px;margin:0;font-family:'IBM Plex Mono',monospace;">VOICE CONFIG</h3>
<button onclick="closeVoiceConfig()"
style="background:none;border:none;color:#666;font-size:18px;cursor:pointer;"></button>
</div>
<!-- Per-voice settings -->
<div id="voiceConfigList" style="font-family:'IBM Plex Mono',monospace;font-size:9px;"></div>
<div style="display:flex;gap:4px;margin-top:12px;">
<button onclick="resetVoiceConfig()"
style="flex:1;padding:8px;font-size:10px;background:#333;color:#FFF;border:none;cursor:pointer;">RESET</button>
<button onclick="closeVoiceConfig()"
style="flex:1;padding:8px;font-size:10px;background:#FA0;color:#000;border:none;cursor:pointer;">DONE</button>
</div>
</div>
</div>
<div id="instructions">
<p>
<span class="c-magenta">WASD: Move Magenta</span> |
<span class="c-cyan"> Arrow Keys: Move Cyan</span><br>
Stay close together. Collect flowers🌹
</p>
<p>🍃Avoid the bushes.</p>
</div>
<div class="btn-row mb8">
<button id="gameBtn" onclick="handleGameButton()">Start</button>
<button id="scoresBtn" onclick="showLeaderboard()" class="is-hidden">🏆 Scores</button>
</div>
<div class="btn-row">
<button id="sfxBtn" onclick="toggleSFX()">🔊 SFX</button>
<button id="musicBtn" onclick="toggleMusic()">🎵 Music</button>
<button id="recBtn" onclick="toggleRecording()" class="is-hidden">⏺ Rec</button>
</div>
</div>
<script>
const canvas = document.getElementById('gameCanvas');
const ctx = canvas.getContext('2d');
ctx.imageSmoothingEnabled = false;
// Title beat bob (smooth, beat-synced)
const titleEl = document.getElementById('title');
if (titleEl) {
const text = titleEl.textContent;
titleEl.textContent = '';
[...text].forEach((ch) => {
const span = document.createElement('span');
span.className = 'title-letter';
span.textContent = ch;
titleEl.appendChild(span);
});
}
const titleAnim = {
raf: null,
lastT: 0,
phase: 0,
bpm: 87,
amps: [],
extraPx: [],
breathPhase: 0,
breathRatePhase: 0
};
function startTitleBeat() {
if (!titleEl || titleAnim.raf) return;
titleAnim.lastT = performance.now();
titleAnim.bpm = breakbeat && breakbeat.bpm ? breakbeat.bpm : 83;
titleAnim.breathPhase = 0;
titleAnim.breathRatePhase = Math.random() * Math.PI * 2;
// Fixed per-letter amplitude variation to keep it cute but not jittery
const lettersForInit = titleEl.querySelectorAll('.title-letter');
titleAnim.amps = Array.from(lettersForInit).map(() => 0.3 + Math.random() * 1.2); // 0.3..1.5
// Occasionally give a letter extra punch (+0..5px) at ~30% chance
titleAnim.extraPx = Array.from(lettersForInit).map(() => (Math.random() < 0.3 ? Math.random() * 5 : 0));
const tick = (t) => {
titleAnim.raf = requestAnimationFrame(tick);
const dt = Math.min(0.05, Math.max(0, (t - titleAnim.lastT) / 1000));
titleAnim.lastT = t;
if (!breakbeat || !breakbeat.isPlaying) {
titleEl.querySelectorAll('.title-letter').forEach((s) => {
s.style.transform = '';
});
return;
}
// Smooth BPM changes to avoid phase snapping/twitch
const targetBpm = breakbeat.bpm || 83;
titleAnim.bpm = titleAnim.bpm * 0.98 + targetBpm * 0.02;
// Faster bobbing (2x faster than current)
titleAnim.phase += dt * (titleAnim.bpm / 60) / 1.75;
// Slow "breathing" scale with gently varying cadence (50%-100% speed)
// Base breath ~ 0.22 cycles/sec (~4.5s). Rate drifts smoothly.
titleAnim.breathRatePhase += dt * 0.35;
const rate01 = (Math.sin(titleAnim.breathRatePhase) + 1) / 2; // 0..1
const breathRate = 0.22 * (0.5 + 0.5 * rate01); // 50%..100%
titleAnim.breathPhase += dt * breathRate;
const breath = (Math.sin(titleAnim.breathPhase * Math.PI * 2) + 1) / 2; // 0..1
const scale = 1 + (breath - 0.5) * 0.07; // subtle (±3.5%)
const letters = titleEl.querySelectorAll('.title-letter');
const baseAmp = 6; // px
for (let i = 0; i < letters.length; i++) {
const l = letters[i];
const localPhase = (titleAnim.phase + i * 0.08) % 1;
// Smooth up/down once per beat
const pulse = Math.sin(Math.PI * localPhase);
const shaped = pulse * pulse;
const amp = (titleAnim.amps[i] || 1);
const extra = (titleAnim.extraPx[i] || 0);
const y = -(baseAmp * amp + extra) * shaped;
l.style.transform = `translateY(${y.toFixed(2)}px) scale(${scale.toFixed(3)})`;
}
};
titleAnim.raf = requestAnimationFrame(tick);
}
// Audio unlock helper for mobile
function resumeAudioIfNeeded() {
try {
initAudio();
if (audioCtx && audioCtx.state === 'suspended') {
audioCtx.resume();
}
} catch (e) { }
}
// === GARDEN BACKGROUND ===
let gardenCanvas = document.getElementById('gardenCanvas');
let gardenCtx = gardenCanvas.getContext('2d');
gardenCtx.imageSmoothingEnabled = false;
const GARDEN_KEY = '2gether_garden';
let gardenFlowers = [];
let currentRunId = null;
function initGarden() {
// Size canvas to viewport
gardenCanvas.width = window.innerWidth;
gardenCanvas.height = window.innerHeight;
// Default to empty garden on load; only load when a run is explicitly selected
gardenFlowers = [];
currentRunId = null;
renderGarden();
}
function saveGarden() {
// Intentionally no-op: garden is generated from saved score on game-over only
}
function regenerateGarden(count) {
gardenFlowers = [];
for (let i = 0; i < count; i++) {
const golden = Math.random() < 0.1;
// Inline add without saving or rendering each time
const centerX = gardenCanvas.width / 2;
const centerY = gardenCanvas.height / 2;
const gameWidth = 500;
const gameHeight = 700;
let x, y, attempts = 0;
do {
x = Math.random() * gardenCanvas.width;
y = Math.random() * gardenCanvas.height;
attempts++;
} while (
attempts < 50 &&
x > centerX - gameWidth / 2 && x < centerX + gameWidth / 2 &&
y > centerY - gameHeight / 2 && y < centerY + gameHeight / 2
);
const size = 12 + Math.random() * 9;
gardenFlowers.push({ x, y, golden, size: Math.floor(size) });
}
renderGarden();
}
function addGardenFlower(golden = false) {
// Find a random spot on the background (avoid center where game is)
const centerX = gardenCanvas.width / 2;
const centerY = gardenCanvas.height / 2;
const gameWidth = 500; // Approximate game container width
const gameHeight = 700; // Approximate game container height
let x, y, attempts = 0;
do {
x = Math.random() * gardenCanvas.width;
y = Math.random() * gardenCanvas.height;
attempts++;
// Avoid placing in center where game container is
} while (
attempts < 50 &&
x > centerX - gameWidth / 2 && x < centerX + gameWidth / 2 &&
y > centerY - gameHeight / 2 && y < centerY + gameHeight / 2
);
// Same size for both golden and regular: 12-21
const size = 12 + Math.random() * 9;
gardenFlowers.push({ x, y, golden, size: Math.floor(size) });
renderGarden();
}
function renderGarden() {
// Transparent background: let the page/body background show through
gardenCtx.clearRect(0, 0, gardenCanvas.width, gardenCanvas.height);
// Draw each flower with grass
gardenFlowers.forEach(flower => {
drawGardenFlower(flower.x, flower.y, flower.size, flower.golden);
});
}
function drawGardenFlower(x, y, size, golden) {
// Taller, skinnier stem - goes DOWN from flower
const stemHeight = size * 0.8;
gardenCtx.fillStyle = '#0F0';
gardenCtx.fillRect(x - 1, y, 2, stemHeight);
// Draw grass tufts - thin blades growing UP from ground
const groundY = y + stemHeight;
const bladeCount = 5 + Math.floor(Math.random() * 4);
for (let i = 0; i < bladeCount; i++) {
// Spread blades around the stem
const bx = x + (Math.random() - 0.5) * size * 1.5;
const bladeHeight = 2 + Math.random() * 4; // Shorter grass
// Darker green for blades closer to stem
const distFromStem = Math.abs(bx - x);
const isClose = distFromStem < size * 0.4;
gardenCtx.fillStyle = isClose ? '#080' : '#0A0'; // Darker near stem
gardenCtx.fillRect(Math.floor(bx), Math.floor(groundY - bladeHeight), 1, Math.floor(bladeHeight));
}
// Draw flower - moved UP a bit so grass doesn't overlap
const flowerY = y - size * 0.15; // Shift flower up
const s = size; // shorthand
const p = Math.floor(s / 3); // petal size
if (golden) {
// Yellow petals (same positions as game)
gardenCtx.fillStyle = '#FF0';
gardenCtx.fillRect(x - p, flowerY - s / 2, p, p); // top-left petal
gardenCtx.fillRect(x, flowerY - s / 2, p, p); // top-right petal
gardenCtx.fillRect(x - s / 2, flowerY - p, p, p); // left petal
gardenCtx.fillRect(x + s / 6, flowerY - p, p, p); // right petal
// Red center
gardenCtx.fillStyle = '#F00';
gardenCtx.fillRect(x - s / 6, flowerY - p, p, p);
} else {
// Red petals (same positions as game)
gardenCtx.fillStyle = '#F00';
gardenCtx.fillRect(x - p, flowerY - s / 2, p, p); // top-left petal
gardenCtx.fillRect(x, flowerY - s / 2, p, p); // top-right petal
gardenCtx.fillRect(x - s / 2, flowerY - p, p, p); // left petal
gardenCtx.fillRect(x + s / 6, flowerY - p, p, p); // right petal
// Yellow center
gardenCtx.fillStyle = '#FF0';
gardenCtx.fillRect(x - s / 6, flowerY - p, p, p);
}
}
// Handle window resize
window.addEventListener('resize', () => {
const oldW = gardenCanvas.width;
const oldH = gardenCanvas.height;
const oldCX = oldW / 2;
const oldCY = oldH / 2;
gardenCanvas.width = window.innerWidth;
gardenCanvas.height = window.innerHeight;
const newCX = gardenCanvas.width / 2;
const newCY = gardenCanvas.height / 2;
const dx = newCX - oldCX;
const dy = newCY - oldCY;
// Keep garden composition centered relative to viewport center
if (dx !== 0 || dy !== 0) {
gardenFlowers.forEach(f => {
f.x += dx;
f.y += dy;
});
}
renderGarden();
});
// Initialize garden on load
initGarden();
// === KONAMI EASTER EGG (garden bloom) ===
const KONAMI_SEQ = ['arrowup', 'arrowup', 'arrowdown', 'arrowdown', 'arrowleft', 'arrowright', 'arrowleft', 'arrowright', 'b', 'a'];
let konamiPos = 0;
let konamiBloomUnlocked = false;
let konamiBloomIntervalId = null;
function startKonamiBlooming() {
if (konamiBloomIntervalId) return;
konamiBloomIntervalId = setInterval(() => {
// 10% golden just like other garden logic
addGardenFlower(Math.random() < 0.1);
}, 700);
}
function updateKonamiButtonPulse() {
const btn = document.getElementById('gameBtn');
if (!btn) return;
const shouldPulse = !!(konamiBloomUnlocked && game && (game.paused || !game.started || game.gameOver));
if (shouldPulse) btn.classList.add('pulsing');
else btn.classList.remove('pulsing');
}
function unlockKonamiBloom() {
if (konamiBloomUnlocked) return;
konamiBloomUnlocked = true;
startKonamiBlooming();
const msg = document.getElementById('message');
if (msg) {
msg.textContent = '🌹 SECRET UNLOCKED: GARDEN BLOOM MODE 🌹';
setTimeout(() => {
if (!game || !game.gameOver) {
if (msg.textContent === '🌹 SECRET UNLOCKED: GARDEN BLOOM MODE 🌹') msg.textContent = '';
}
}, 3500);
}
playTreasureJingle();
updateKonamiButtonPulse();
}
// 3-bit color palette
const colors = {
black: '#000',
white: '#FFF',
red: '#F00',
green: '#0F0',
blue: '#00F',
yellow: '#FF0',
cyan: '#0FF',
magenta: '#F0F'
};
const BASE_LINE_LENGTH = 100; // Starting/minimum line length
let game = {
score: 0,
level: 1,
loveMeter: 3,
paused: false,
gameOver: false,
started: false,
particles: [],
hearts: [],
obstacles: [],
shakeTimer: 0,
drainFlash: 0,
combo: 0,
comboTimer: 0,
lastCollectTime: 0,
lineBonus: 0, // Extra line length from collecting flowers
lineBonusTimer: 0, // Decays over time
graceUntil: 0,
gardenFlowerCount: 0,
movementUnlocked: true,
lastCloseChime: 0
};
// Leaderboard
const LEADERBOARD_KEY = '2gether_leaderboard';
let currentName = '';
function getLeaderboard() {
try {
return JSON.parse(localStorage.getItem(LEADERBOARD_KEY)) || [];
} catch { return []; }
}
function saveScore(name, score, level) {
const lb = getLeaderboard();
// Overwrite any existing entry with the same name (treat as saved game slot)
const next = lb.filter(e => e.name !== name);
next.push({ name, score, level, gardenFlowerCount: game.gardenFlowerCount || 0, date: Date.now() });
const updated = next;
updated.sort((a, b) => b.score - a.score);
updated.splice(10); // Keep top 10
localStorage.setItem(LEADERBOARD_KEY, JSON.stringify(updated));
updateScoresButton();
return updated;
}
function renderLeaderboard(currentScore = null) {
const lb = getLeaderboard();
const container = document.getElementById('leaderboardList');
if (!container) return;
container.innerHTML = lb.map((entry, i) => {
const isCurrent = currentScore !== null && entry.score === currentScore && entry.date === Math.max(...lb.filter(e => e.score === currentScore).map(e => e.date));
return `<div class="lb-entry lb-entry-row${isCurrent ? ' current' : ''}">
<span class="lb-entry-name">${i + 1}. ${entry.name}</span>
<span class="lb-entry-meta">L${entry.level} - ${entry.score}pts</span>
<button class="lb-entry-play" onclick="startRunFromLeaderboard(${i})">Play</button>
</div>`;
}).join('') || '<div class="lb-empty">No scores yet!</div>';
}
function startRunFromLeaderboard(index) {
const lb = getLeaderboard();
const entry = lb[index];
if (!entry) return;
regenerateGarden(entry.gardenFlowerCount || 0);
startGameWithSeed(entry.score || 0, entry.level || 1, entry.gardenFlowerCount || 0);
closeLeaderboard();
}
function startGameWithSeed(seedScore, seedLevel, seedGardenFlowerCount) {
startGame();
game.score = seedScore;
game.level = seedLevel;
game.gardenFlowerCount = seedGardenFlowerCount || 0;
const hearts = '❤️'.repeat(Math.ceil(game.loveMeter));
document.getElementById('score').textContent = `Level: ${game.level} | Score: ${game.score} | Love Meter: ${hearts}`;
}
// Audio context for sound effects
let audioCtx = null;
let sfxEnabled = true;
let musicEnabled = true;
let musicGain = null;
let noiseBuffers = {};
// Safari detection and safety limits
const isSafari = /^((?!chrome|android).)*safari/i.test(navigator.userAgent);
const safariLimits = {
maxResonance: 8, // Cap Q to prevent self-oscillation
maxDrive: 30, // Limit drive to prevent harsh clipping
maxChorus: 50, // Limit chorus to prevent phasing artifacts
maxBitCrush: 70 // Limit bitcrush intensity
};
if (isSafari) console.log('[AUDIO] Safari detected - safety limits active:', safariLimits);
function safariSafe(param, value) {
if (!isSafari) return value;
const limit = safariLimits[param];
return limit !== undefined ? Math.min(value, limit) : value;
}
// WAV Recording
let mediaRecorder = null;
let recordedChunks = [];
let isRecording = false;
let recordingStartTime = 0;
let recordingDest = null;
let recordingPending = false; // Waiting for bar boundary to start
let stopRecordingPending = false; // Waiting for bar boundary to stop
let analyser = null;
// Lead Voice FX Bus nodes
let leadFxDelay = null;
let leadFxDelayFeedback = null;
let leadFxDelayWet = null;
let leadFxReverb = null;
let leadFxReverbWet = null;
let leadFxDrive = null;
let leadFxChorus = null;
let leadFxChorusLfo = null;
let leadFxChorusWet = null;
// AudioWorklet bitcrusher for Safari compatibility
let bitCrusherWorkletReady = false;
const bitCrusherProcessorCode = `
class BitCrusherProcessor extends AudioWorkletProcessor {
static get parameterDescriptors() {
return [
{ name: 'bits', defaultValue: 16, minValue: 1, maxValue: 16 },
{ name: 'sampleRateReduction', defaultValue: 1, minValue: 1, maxValue: 32 }
];
}
constructor() {
super();
this.lastSample = [0, 0];
this.sampleCounter = 0;
}
process(inputs, outputs, parameters) {
const input = inputs[0];
const output = outputs[0];
if (!input || !input.length) return true;
const bits = parameters.bits[0] || 16;
const srr = Math.floor(parameters.sampleRateReduction[0] || 1);
const steps = Math.pow(2, bits);
for (let channel = 0; channel < input.length; channel++) {
const inputChannel = input[channel];
const outputChannel = output[channel];
for (let i = 0; i < inputChannel.length; i++) {
this.sampleCounter++;
if (this.sampleCounter >= srr) {
this.sampleCounter = 0;
// Bit reduction
this.lastSample[channel] = Math.round(inputChannel[i] * steps) / steps;
}
outputChannel[i] = this.lastSample[channel];
}
}
return true;
}
}
registerProcessor('bitcrusher-processor', BitCrusherProcessor);
`;
async function initBitCrusherWorklet(ctx) {
if (bitCrusherWorkletReady || !ctx.audioWorklet) return;
try {
const blob = new Blob([bitCrusherProcessorCode], { type: 'application/javascript' });
const url = URL.createObjectURL(blob);
await ctx.audioWorklet.addModule(url);
URL.revokeObjectURL(url);
bitCrusherWorkletReady = true;
console.log('[AUDIO] BitCrusher AudioWorklet ready');
} catch (e) {
console.warn('[AUDIO] AudioWorklet not supported, using WaveShaper fallback:', e);
}
}
function createBitCrusher(ctx, bits = 8, sampleRateReduction = 1) {
if (bitCrusherWorkletReady) {
try {
const node = new AudioWorkletNode(ctx, 'bitcrusher-processor');
node.parameters.get('bits').value = bits;
node.parameters.get('sampleRateReduction').value = sampleRateReduction;
return node;
} catch (e) {
console.warn('[AUDIO] AudioWorkletNode failed, using fallback');
}
}
// Fallback: WaveShaper-based bitcrusher (may have Safari issues)
const crusher = ctx.createWaveShaper();
const steps = Math.pow(2, bits);
const curve = new Float32Array(65536);
for (let i = 0; i < 65536; i++) {
const x = (i / 32768) - 1;
curve[i] = Math.round(x * steps) / steps;
}
crusher.curve = curve;
return crusher;
}
function initAudio() {
if (!audioCtx) {
audioCtx = new (window.AudioContext || window.webkitAudioContext)();
// Initialize AudioWorklet for Safari-safe bitcrusher
initBitCrusherWorklet(audioCtx);
// Create noise buffers for drums
noiseBuffers.short = createNoiseBuffer(0.15);
noiseBuffers.medium = createNoiseBuffer(0.3);
// Create analyser for oscilloscope
analyser = audioCtx.createAnalyser();
analyser.fftSize = 2048;
// Create music gain node
musicGain = audioCtx.createGain();
// === MASTER FX CHAIN (Breakdown/Build DJ System) ===
// Isolator: 3-band EQ for frequency isolation
const isolatorLow = audioCtx.createBiquadFilter();
isolatorLow.type = 'lowshelf';
isolatorLow.frequency.value = 200;
isolatorLow.gain.value = 0; // 0dB = flat
const isolatorMid = audioCtx.createBiquadFilter();
isolatorMid.type = 'peaking';
isolatorMid.frequency.value = 2000;
isolatorMid.Q.value = 1.0;
isolatorMid.gain.value = 0; // 0dB = flat
const isolatorHigh = audioCtx.createBiquadFilter();
isolatorHigh.type = 'highshelf';
isolatorHigh.frequency.value = 8000;
isolatorHigh.gain.value = 0; // 0dB = flat
// Echo delay: BPM-synced for riser effects
const breakdownEchoDelay = audioCtx.createDelay(2.0);
breakdownEchoDelay.delayTime.value = 0.25; // Default 1/4-note
const breakdownEchoFeedback = audioCtx.createGain();
breakdownEchoFeedback.gain.value = 0.2;
const breakdownEchoWet = audioCtx.createGain();
breakdownEchoWet.gain.value = 0; // Start dry
const breakdownEchoDry = audioCtx.createGain();
breakdownEchoDry.gain.value = 1.0;
// Gate: For stutter/denial effects
const breakdownGate = audioCtx.createGain();
breakdownGate.gain.value = 1.0; // Start open (bypassed)
// Connect FX chain: musicGain → isolators → echo split → gate → analyser
musicGain.connect(isolatorLow);
isolatorLow.connect(isolatorMid);
isolatorMid.connect(isolatorHigh);
// Echo: Split dry/wet
isolatorHigh.connect(breakdownEchoDry);
isolatorHigh.connect(breakdownEchoDelay);
breakdownEchoDelay.connect(breakdownEchoFeedback);
breakdownEchoFeedback.connect(breakdownEchoDelay); // Feedback loop
breakdownEchoDelay.connect(breakdownEchoWet);
// Merge dry + wet → gate
const breakdownEchoMerge = audioCtx.createGain();
breakdownEchoDry.connect(breakdownEchoMerge);
breakdownEchoWet.connect(breakdownEchoMerge);
breakdownEchoMerge.connect(breakdownGate);
// Store FX nodes globally for automation
window.breakdownFX = {
isolatorLow,
isolatorMid,
isolatorHigh,
echoDelay: breakdownEchoDelay,
echoFeedback: breakdownEchoFeedback,
echoWet: breakdownEchoWet,
echoDry: breakdownEchoDry,
gate: breakdownGate
};
// Safari: Add DC blocker (high-pass filter) to eliminate low-frequency buzz
if (isSafari) {
const dcBlocker = audioCtx.createBiquadFilter();
dcBlocker.type = 'highpass';
dcBlocker.frequency.value = 20; // Block sub-20Hz rumble/DC offset
dcBlocker.Q.value = 0.7;
breakdownGate.connect(dcBlocker);
dcBlocker.connect(analyser);
console.log('[AUDIO] Safari: DC blocker enabled (20Hz highpass)');
} else {
breakdownGate.connect(analyser);
}
analyser.connect(audioCtx.destination);
// Setup recording destination for WAV export
recordingDest = audioCtx.createMediaStreamDestination();
musicGain.connect(recordingDest);
// Initialize Lead Voice FX Bus
initLeadFxBus();
}
return audioCtx;
}
function initLeadFxBus() {
// Delay FX
leadFxDelay = audioCtx.createDelay(1.0);
leadFxDelay.delayTime.value = 0.25;
leadFxDelayFeedback = audioCtx.createGain();
leadFxDelayFeedback.gain.value = 0.4;
leadFxDelayWet = audioCtx.createGain();
leadFxDelayWet.gain.value = 0;
leadFxDelay.connect(leadFxDelayFeedback);
leadFxDelayFeedback.connect(leadFxDelay);
leadFxDelay.connect(leadFxDelayWet);
leadFxDelayWet.connect(musicGain);
// Simple reverb via feedback delay network
leadFxReverb = audioCtx.createConvolver();
leadFxReverbWet = audioCtx.createGain();
leadFxReverbWet.gain.value = 0;
// Create simple impulse response
const reverbLen = audioCtx.sampleRate * 1.5;
const reverbBuf = audioCtx.createBuffer(2, reverbLen, audioCtx.sampleRate);
for (let c = 0; c < 2; c++) {
const data = reverbBuf.getChannelData(c);
for (let i = 0; i < reverbLen; i++) {
data[i] = (Math.random() * 2 - 1) * Math.exp(-i / (reverbLen * 0.15));
}
}
leadFxReverb.buffer = reverbBuf;
leadFxReverb.connect(leadFxReverbWet);
leadFxReverbWet.connect(musicGain);
// Drive (soft saturation)
leadFxDrive = audioCtx.createWaveShaper();
leadFxDrive.curve = makeLeadDriveCurve(0);
leadFxDrive.connect(musicGain);
// Chorus (LFO-modulated delay)
leadFxChorus = audioCtx.createDelay(0.05);
leadFxChorus.delayTime.value = 0.015;
leadFxChorusLfo = audioCtx.createOscillator();
leadFxChorusLfo.frequency.value = 1.5;
const chorusLfoGain = audioCtx.createGain();
// Safari: reduce LFO modulation depth to prevent artifacts during silence
chorusLfoGain.gain.value = isSafari ? 0.001 : 0.003;
leadFxChorusLfo.connect(chorusLfoGain);
chorusLfoGain.connect(leadFxChorus.delayTime);
leadFxChorusLfo.start();
leadFxChorusWet = audioCtx.createGain();
leadFxChorusWet.gain.value = 0;
leadFxChorus.connect(leadFxChorusWet);
leadFxChorusWet.connect(musicGain);
}
function silenceLeadFxBus() {
// Mute all FX wet signals to kill residual noise (feedback loops, LFO artifacts)
if (!audioCtx) return;
const t = audioCtx.currentTime;
const fadeTime = 0.05;
if (leadFxDelayWet) leadFxDelayWet.gain.setTargetAtTime(0, t, fadeTime);
if (leadFxReverbWet) leadFxReverbWet.gain.setTargetAtTime(0, t, fadeTime);
if (leadFxChorusWet) leadFxChorusWet.gain.setTargetAtTime(0, t, fadeTime);
// Clear delay feedback to stop any ringing
if (leadFxDelayFeedback) leadFxDelayFeedback.gain.setTargetAtTime(0, t, fadeTime);
// Also silence perf pad FX if active
if (window.perfPadFX) {
if (window.perfPadFX.delayMix) window.perfPadFX.delayMix.gain.setTargetAtTime(0, t, fadeTime);
if (window.perfPadFX.delayFeedback) window.perfPadFX.delayFeedback.gain.setTargetAtTime(0, t, fadeTime);
}
}
function makeLeadDriveCurve(amount) {
const samples = 256;
const curve = new Float32Array(samples);
const k = amount * 50;
for (let i = 0; i < samples; i++) {
const x = (i * 2) / samples - 1;
curve[i] = k > 0 ? Math.tanh(x * (1 + k)) / Math.tanh(1 + k) : x;
}
return curve;
}
function toggleRecording() {
if (!window.MediaRecorder) return;
if (!isRecording && !recordingPending) {
// Queue recording to start on next bar
recordingPending = true;
flashRecButton();
updateRecButtons('⏳ Wait');
} else if (isRecording && !stopRecordingPending) {
// Queue recording to stop on next bar
stopRecordingPending = true;
flashRecButton();
updateRecButtons('⏳ End');
}
}
function updateRecButtons(text, bg = '') {
const mainBtn = document.getElementById('recBtn');
const modalBtn = document.getElementById('musicRecBtn');
const seqBtn = document.getElementById('seqRecBtn');
if (mainBtn) { mainBtn.textContent = text; if (bg !== undefined) mainBtn.style.background = bg; }
if (modalBtn) { modalBtn.textContent = text; if (bg !== undefined) modalBtn.style.background = bg || '#F00'; }
if (seqBtn) {
seqBtn.textContent = text;
if (bg !== undefined) {
seqBtn.style.background = bg || '#333';
seqBtn.style.borderColor = isRecording ? '#F00' : '#F00';
}
}
}
function flashRecButton() {
const btn = document.getElementById('recBtn');
const seqBtn = document.getElementById('seqRecBtn');
if (btn) {
btn.classList.add('pulsing');
setTimeout(() => {
btn.classList.remove('pulsing');
btn.style.background = isRecording ? '#F00' : '';
}, 1500);
}
if (seqBtn) {
seqBtn.classList.add('pulsing');
setTimeout(() => {
seqBtn.classList.remove('pulsing');
seqBtn.style.background = isRecording ? '#F00' : '#333';
}, 1500);
}
}
// Called from scheduler at bar boundary (step 0)
function checkRecordingBoundary() {
if (recordingPending) {
recordingPending = false;
actuallyStartRecording();
}
if (stopRecordingPending) {
stopRecordingPending = false;
actuallyStopRecording();
}
}
function actuallyStartRecording() {
if (!audioCtx || !recordingDest) {
initAudio();
}
recordedChunks = [];
isRecording = true;
recordingStartTime = Date.now();
// Use webm for broader support
const options = { mimeType: 'audio/webm;codecs=opus' };
try {
mediaRecorder = new MediaRecorder(recordingDest.stream, options);
} catch (e) {
mediaRecorder = new MediaRecorder(recordingDest.stream);
}
mediaRecorder.ondataavailable = (e) => {
if (e.data.size > 0) {
recordedChunks.push(e.data);
}
};
mediaRecorder.onstop = () => {
downloadRecording();
};
mediaRecorder.start(100); // Collect data frequently for accuracy
updateRecButtons('⏹ Stop', '#F00');
}
function actuallyStopRecording() {
if (mediaRecorder && isRecording) {
isRecording = false;
mediaRecorder.stop();
updateRecButtons('⏺ Rec', '');
}
}
function downloadRecording() {
if (recordedChunks.length === 0) return;
const blob = new Blob(recordedChunks, { type: 'audio/webm' });
const duration = Math.round((Date.now() - recordingStartTime) / 1000);
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `2gether_music_${duration}s.webm`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
recordedChunks = [];
}
function createNoiseBuffer(duration) {
const ctx = initAudio();
const sampleRate = ctx.sampleRate;
const length = sampleRate * duration;
const buffer = ctx.createBuffer(1, length, sampleRate);
const data = buffer.getChannelData(0);
for (let i = 0; i < length; i++) {
data[i] = Math.random() * 2 - 1;
}
return buffer;
}
function toggleSFX() {
const wasEnabled = sfxEnabled;
sfxEnabled = !sfxEnabled;
// SFX toggle now mutes ALL audio (SFX + game music + leaderboard music)
if (musicGain) {
musicGain.gain.value = sfxEnabled ? (musicEnabled ? 1 : 0) : 0;
}
document.getElementById('sfxBtn').textContent = sfxEnabled ? '🔊 SFX' : '🔇 SFX';
document.getElementById('sfxBtn').style.opacity = sfxEnabled ? '1' : '0.5';
// Regenerate new track when SFX is turned ON (not when turning off)
if (sfxEnabled && !wasEnabled && musicEnabled) {
randomizePatterns(); // Generate fresh patterns for variety
// Also randomize BPM in range
if (breakbeat) {
breakbeat.bpm = musicSettings.bpmMin + Math.random() * (musicSettings.bpmMax - musicSettings.bpmMin);
}
logEvolution('SFX ON → NEW TRACK');
}
}
// Music settings state (user-configurable)
const musicSettings = {
bpmMin: 83,
bpmMax: 124,
kitChance: 0.51,
chorusChance: 0.28,
rallyThreshold: 13,
motifBars: 4, // 1, 2, 4, or 8 bars for motif phrases
scale: 'minor', // minor, major, dorian, phrygian, pentatonic, mixolydian
motifVoice: '', // empty = random, or force specific voice
// Kit transition settings (jam band style)
transitionBarsMin: 6, // Min bars to spread transition over
transitionBarsMax: 8, // Max bars
morphChance: 0.10, // Chance for step-by-step morph vs instant replace
kitStaleMax: 32, // Force kit change after this many bars
// Breakdown/Build system (DJ club transitions)
breakdownChance: 0.20, // Probability to trigger breakdown when stale (0-1)
breakdownBarsMin: 8, // Minimum bars for full sequence
breakdownBarsMax: 12, // Maximum bars for full sequence
breakdownIntensity: 1.0, // Intensity multiplier (0.5 = subtle, 1.0 = full club)
// Extended voice prevalence (0-1, probability to include in regeneration)
acidPrevalence: 0.35, // TB-303 Acid Bass
popPrevalence: 0.25, // Drum Pop
stabPrevalence: 0.30, // FM Brass Stab
whammyPrevalence: 0.28, // FM Pitch Whammy
xpolyPrevalence: 0.30, // XPoly Lead
squarepusherPrevalence: 0.05, // Squarepusher Bass
uziqPrevalence: 0.20 // µ-Ziq Melody
};
// Scale definitions (semitones from root)
const SCALE_INTERVALS = {
major: [0, 2, 4, 5, 7, 9, 11],
minor: [0, 2, 3, 5, 7, 8, 10],
dorian: [0, 2, 3, 5, 7, 9, 10],
phrygian: [0, 1, 3, 5, 7, 8, 10],
pentatonic: [0, 3, 5, 7, 10],
mixolydian: [0, 2, 4, 5, 7, 9, 10]
};
function toggleMusic() {
// Open music settings modal instead of simple toggle
openMusicSettings();
}
// Track if we paused the game for the music modal
let musicModalPausedGame = false;
function openMusicSettings() {
// Auto-pause game if it's running (not already paused, not game over)
if (game && game.started && !game.paused && !game.gameOver) {
game.paused = true;
musicModalPausedGame = true;
document.getElementById('gameBtn').textContent = 'Resume';
document.getElementById('pauseOverlay').style.display = 'flex';
document.getElementById('pauseText').textContent = '🎵 MUSIC';
}
// Sync UI with current settings
document.getElementById('musicBpmMin').value = musicSettings.bpmMin;
document.getElementById('musicBpmMax').value = musicSettings.bpmMax;
document.getElementById('musicKitChance').value = Math.round(musicSettings.kitChance * 100);
document.getElementById('musicKitChanceVal').textContent = Math.round(musicSettings.kitChance * 100) + '%';
document.getElementById('musicChorusChance').value = Math.round(musicSettings.chorusChance * 100);
document.getElementById('musicChorusChanceVal').textContent = Math.round(musicSettings.chorusChance * 100) + '%';
document.getElementById('musicRallyThreshold').value = musicSettings.rallyThreshold;
document.getElementById('musicRallyThresholdVal').textContent = musicSettings.rallyThreshold;
document.getElementById('musicMotifBars').value = musicSettings.motifBars;
document.getElementById('musicMotifVoice').value = musicSettings.motifVoice || '';
document.getElementById('musicScale').value = musicSettings.scale;
// Sync extended voice prevalence sliders
if (document.getElementById('musicAcidPrevalence')) {
document.getElementById('musicAcidPrevalence').value = Math.round(musicSettings.acidPrevalence * 100);
document.getElementById('musicAcidPrevalenceVal').textContent = Math.round(musicSettings.acidPrevalence * 100) + '%';
}
if (document.getElementById('musicPopPrevalence')) {
document.getElementById('musicPopPrevalence').value = Math.round(musicSettings.popPrevalence * 100);
document.getElementById('musicPopPrevalenceVal').textContent = Math.round(musicSettings.popPrevalence * 100) + '%';
}
if (document.getElementById('musicStabPrevalence')) {
document.getElementById('musicStabPrevalence').value = Math.round(musicSettings.stabPrevalence * 100);
document.getElementById('musicStabPrevalenceVal').textContent = Math.round(musicSettings.stabPrevalence * 100) + '%';
}
if (document.getElementById('musicWhammyPrevalence')) {
document.getElementById('musicWhammyPrevalence').value = Math.round(musicSettings.whammyPrevalence * 100);
document.getElementById('musicWhammyPrevalenceVal').textContent = Math.round(musicSettings.whammyPrevalence * 100) + '%';
}
if (document.getElementById('musicXpolyPrevalence')) {
document.getElementById('musicXpolyPrevalence').value = Math.round(musicSettings.xpolyPrevalence * 100);
document.getElementById('musicXpolyPrevalenceVal').textContent = Math.round(musicSettings.xpolyPrevalence * 100) + '%';
}
if (document.getElementById('musicSquarepusherPrevalence')) {
document.getElementById('musicSquarepusherPrevalence').value = Math.round(musicSettings.squarepusherPrevalence * 100);
document.getElementById('musicSquarepusherPrevalenceVal').textContent = Math.round(musicSettings.squarepusherPrevalence * 100) + '%';
}
if (document.getElementById('musicUziqPrevalence')) {
document.getElementById('musicUziqPrevalence').value = Math.round(musicSettings.uziqPrevalence * 100);
document.getElementById('musicUziqPrevalenceVal').textContent = Math.round(musicSettings.uziqPrevalence * 100) + '%';
}
if (document.getElementById('musicBreakdownChance')) {
document.getElementById('musicBreakdownChance').value = Math.round(musicSettings.breakdownChance * 100);
document.getElementById('musicBreakdownChanceVal').textContent = Math.round(musicSettings.breakdownChance * 100) + '%';
}
if (document.getElementById('musicBreakdownIntensity')) {
document.getElementById('musicBreakdownIntensity').value = Math.round(musicSettings.breakdownIntensity * 100);
document.getElementById('musicBreakdownIntensityVal').textContent = Math.round(musicSettings.breakdownIntensity * 100) + '%';
}
// Sync BPM slider
const bpmSlider = document.getElementById('musicBpmSlider');
if (bpmSlider) {
bpmSlider.min = musicSettings.bpmMin;
bpmSlider.max = musicSettings.bpmMax;
bpmSlider.value = (breakbeat && breakbeat.bpm) ? Math.round(breakbeat.bpm) : Math.round((musicSettings.bpmMin + musicSettings.bpmMax) / 2);
document.getElementById('musicBpmSliderVal').textContent = bpmSlider.value;
}
// Populate kit dropdown
populateKitDropdown();
// Sync transition settings UI
syncTransitionUI();
// Dim kit chance if a kit is forced
const kitChanceRow = document.getElementById('musicKitChance').parentElement;
if (kitChanceRow) {
kitChanceRow.style.opacity = (breakbeat && breakbeat.kitOverrideName) ? '0.4' : '1';
}
// Update status display and play/stop button
updateMusicStatusDisplay();
updatePlayStopButton();
// Sync Drum Mind settings with actual values
document.getElementById('gameGhostProb').value = seqGhostProb;
document.getElementById('gameGhostProbVal').textContent = seqGhostProb + '%';
document.getElementById('gameEvolveRate').value = seqEvolveRate;
document.getElementById('gameEvolveRateVal').textContent = seqEvolveRate + '%';
// Sync Emergent Melody settings
document.getElementById('gameMotifChance').value = musicSettings.motifChance || 55;
document.getElementById('gameMotifChanceVal').textContent = (musicSettings.motifChance || 55) + '%';
document.getElementById('gameCallResponse').value = musicSettings.callResponse || 65;
document.getElementById('gameCallResponseVal').textContent = (musicSettings.callResponse || 65) + '%';
// Start live UI sync for BPM etc
startLiveUISync();
document.getElementById('musicSettingsOverlay').style.display = 'flex';
}
function closeMusicSettings() {
document.getElementById('musicSettingsOverlay').style.display = 'none';
// Stop live UI sync
stopLiveUISync();
// Auto-unpause game if we paused it for the modal
if (musicModalPausedGame && game && game.paused && !game.gameOver) {
game.paused = false;
musicModalPausedGame = false;
document.getElementById('gameBtn').textContent = 'Pause';
document.getElementById('pauseOverlay').style.display = 'none';
gameLoop(); // Resume game loop
}
}
function updateMusicSettings() {
musicSettings.bpmMin = parseInt(document.getElementById('musicBpmMin').value) || 78;
musicSettings.bpmMax = parseInt(document.getElementById('musicBpmMax').value) || 88;
// Ensure min <= max
if (musicSettings.bpmMin > musicSettings.bpmMax) {
musicSettings.bpmMax = musicSettings.bpmMin;
document.getElementById('musicBpmMax').value = musicSettings.bpmMax;
}
const kitChance = parseInt(document.getElementById('musicKitChance').value);
musicSettings.kitChance = kitChance / 100;
document.getElementById('musicKitChanceVal').textContent = kitChance + '%';
const chorusChance = parseInt(document.getElementById('musicChorusChance').value);
musicSettings.chorusChance = chorusChance / 100;
document.getElementById('musicChorusChanceVal').textContent = chorusChance + '%';
const rallyThreshold = parseInt(document.getElementById('musicRallyThreshold').value);
musicSettings.rallyThreshold = rallyThreshold;
document.getElementById('musicRallyThresholdVal').textContent = rallyThreshold;
const motifBars = parseInt(document.getElementById('musicMotifBars').value) || 2;
musicSettings.motifBars = motifBars;
const scale = document.getElementById('musicScale').value || 'minor';
const prevScale = musicSettings.scale;
musicSettings.scale = scale;
// Apply scale change immediately to breakbeat
applyScaleToBreakbeat();
if (prevScale !== scale) {
logEvolution(`SCALE: ${scale}`);
}
// Extended voice prevalence settings
const acidPrevalence = parseInt(document.getElementById('musicAcidPrevalence')?.value || 35);
musicSettings.acidPrevalence = acidPrevalence / 100;
if (document.getElementById('musicAcidPrevalenceVal')) {
document.getElementById('musicAcidPrevalenceVal').textContent = acidPrevalence + '%';
}
const popPrevalence = parseInt(document.getElementById('musicPopPrevalence')?.value || 25);
musicSettings.popPrevalence = popPrevalence / 100;
if (document.getElementById('musicPopPrevalenceVal')) {
document.getElementById('musicPopPrevalenceVal').textContent = popPrevalence + '%';
}
const stabPrevalence = parseInt(document.getElementById('musicStabPrevalence')?.value || 30);
musicSettings.stabPrevalence = stabPrevalence / 100;
if (document.getElementById('musicStabPrevalenceVal')) {
document.getElementById('musicStabPrevalenceVal').textContent = stabPrevalence + '%';
}
const whammyPrevalence = parseInt(document.getElementById('musicWhammyPrevalence')?.value || 28);
musicSettings.whammyPrevalence = whammyPrevalence / 100;
if (document.getElementById('musicWhammyPrevalenceVal')) {
document.getElementById('musicWhammyPrevalenceVal').textContent = whammyPrevalence + '%';
}
const xpolyPrevalence = parseInt(document.getElementById('musicXpolyPrevalence')?.value || 30);
musicSettings.xpolyPrevalence = xpolyPrevalence / 100;
if (document.getElementById('musicXpolyPrevalenceVal')) {
document.getElementById('musicXpolyPrevalenceVal').textContent = xpolyPrevalence + '%';
}
const squarepusherPrevalence = parseInt(document.getElementById('musicSquarepusherPrevalence')?.value || 5);
musicSettings.squarepusherPrevalence = squarepusherPrevalence / 100;
if (document.getElementById('musicSquarepusherPrevalenceVal')) {
document.getElementById('musicSquarepusherPrevalenceVal').textContent = squarepusherPrevalence + '%';
}
const uziqPrevalence = parseInt(document.getElementById('musicUziqPrevalence')?.value || 20);
musicSettings.uziqPrevalence = uziqPrevalence / 100;
if (document.getElementById('musicUziqPrevalenceVal')) {
document.getElementById('musicUziqPrevalenceVal').textContent = uziqPrevalence + '%';
}
const breakdownChance = parseInt(document.getElementById('musicBreakdownChance')?.value || 20);
musicSettings.breakdownChance = breakdownChance / 100;
if (document.getElementById('musicBreakdownChanceVal')) {
document.getElementById('musicBreakdownChanceVal').textContent = breakdownChance + '%';
}
const breakdownIntensity = parseInt(document.getElementById('musicBreakdownIntensity')?.value || 100);
musicSettings.breakdownIntensity = breakdownIntensity / 100;
if (document.getElementById('musicBreakdownIntensityVal')) {
document.getElementById('musicBreakdownIntensityVal').textContent = breakdownIntensity + '%';
}
updateMusicStatusDisplay();
}
function updateMotifVoice() {
const select = document.getElementById('musicMotifVoice');
const voice = select ? select.value : '';
const prevVoice = musicSettings.motifVoice;
musicSettings.motifVoice = voice;
if (voice && voice !== prevVoice) {
logEvolution(`MOTIF VOICE: ${voice}`);
}
}
function updateBassStyle() {
const select = document.getElementById('musicBassStyle');
const style = select ? select.value : 'classic';
seqBassStyle = style;
logEvolution(`BASS STYLE: ${style === 'tuss' ? 'Tuss (Juicy Modulation)' : 'Classic (SID)'}`);
}
function updateTransitionSettings() {
const staleMax = parseInt(document.getElementById('musicKitStaleMax').value) || 12;
musicSettings.kitStaleMax = staleMax;
document.getElementById('musicKitStaleMaxVal').textContent = staleMax;
const transitionBars = parseInt(document.getElementById('musicTransitionBars').value) || 4;
musicSettings.transitionBarsMin = transitionBars;
musicSettings.transitionBarsMax = Math.min(transitionBars + 2, 8);
document.getElementById('musicTransitionBarsVal').textContent = `${transitionBars}-${musicSettings.transitionBarsMax}`;
const morphChance = parseInt(document.getElementById('musicMorphChance').value) || 40;
musicSettings.morphChance = morphChance / 100;
document.getElementById('musicMorphChanceVal').textContent = morphChance + '%';
}
function syncTransitionUI() {
// Sync transition UI with current settings
document.getElementById('musicKitStaleMax').value = musicSettings.kitStaleMax || 12;
document.getElementById('musicKitStaleMaxVal').textContent = musicSettings.kitStaleMax || 12;
document.getElementById('musicTransitionBars').value = musicSettings.transitionBarsMin || 4;
const min = musicSettings.transitionBarsMin || 4;
const max = musicSettings.transitionBarsMax || 6;
document.getElementById('musicTransitionBarsVal').textContent = `${min}-${max}`;
const morphPct = Math.round((musicSettings.morphChance || 0.40) * 100);
document.getElementById('musicMorphChance').value = morphPct;
document.getElementById('musicMorphChanceVal').textContent = morphPct + '%';
}
// === ADVANCED MUSIC SETTINGS ===
let gameAdvancedOpen = false;
let extendedVoicesOpen = false;
let kitPreviewMode = false;
let enabledKits = null; // null = all enabled
function toggleAdvancedMusic() {
gameAdvancedOpen = !gameAdvancedOpen;
const panel = document.getElementById('advancedMusicPanel');
const toggle = document.getElementById('advancedMusicToggle');
if (panel) panel.style.display = gameAdvancedOpen ? 'block' : 'none';
if (toggle) toggle.textContent = gameAdvancedOpen ? '▼' : '▶';
if (gameAdvancedOpen) {
populateKitPool();
populateKitPreviewSelect();
syncAdvancedUI();
}
}
function toggleExtendedVoices() {
extendedVoicesOpen = !extendedVoicesOpen;
const panel = document.getElementById('extendedVoicesPanel');
const toggle = document.getElementById('extendedVoicesToggle');
if (panel) panel.style.display = extendedVoicesOpen ? 'block' : 'none';
if (toggle) toggle.textContent = extendedVoicesOpen ? '▼' : '▶';
}
function syncAdvancedUI() {
// Sync voice mutes
if (breakbeat && breakbeat.voiceMute) {
// Drums
document.getElementById('gameMuteKick').checked = !!breakbeat.voiceMute.kick;
document.getElementById('gameMuteSnare').checked = !!breakbeat.voiceMute.snare;
document.getElementById('gameMuteHihat').checked = !!breakbeat.voiceMute.hihat;
// Melodic
document.getElementById('gameMuteBass').checked = !!breakbeat.voiceMute.bass;
document.getElementById('gameMutePad').checked = !!breakbeat.voiceMute.pad;
// Individual leads
document.getElementById('gameMuteSidLead').checked = !!breakbeat.voiceMute.xsidLead;
document.getElementById('gameMuteChipLead').checked = !!breakbeat.voiceMute.chipLead;
document.getElementById('gameMuteXpoly').checked = !!breakbeat.voiceMute.xpoly;
document.getElementById('gameMuteXwave').checked = !!breakbeat.voiceMute.xwave;
document.getElementById('gameMuteFmWhammy').checked = !!breakbeat.voiceMute.fmWhammy;
document.getElementById('gameMuteFmBrass').checked = !!breakbeat.voiceMute.fmBrass;
document.getElementById('gameMuteFmPad').checked = !!breakbeat.voiceMute.fmPad;
}
// Sync Drum Mind
document.getElementById('gameGhostProb').value = musicSettings.ghostProb || 40;
document.getElementById('gameGhostProbVal').textContent = (musicSettings.ghostProb || 40) + '%';
document.getElementById('gameEvolveRate').value = musicSettings.evolveRate || 30;
document.getElementById('gameEvolveRateVal').textContent = (musicSettings.evolveRate || 30) + '%';
// Sync Emergent Melody
document.getElementById('gameMotifChance').value = musicSettings.motifChance || 55;
document.getElementById('gameMotifChanceVal').textContent = (musicSettings.motifChance || 55) + '%';
document.getElementById('gameCallResponse').value = musicSettings.callResponse || 65;
document.getElementById('gameCallResponseVal').textContent = (musicSettings.callResponse || 65) + '%';
}
function updateGameVoiceMutes() {
if (!breakbeat) return;
if (!breakbeat.voiceMute) breakbeat.voiceMute = {};
// Drums
breakbeat.voiceMute.kick = document.getElementById('gameMuteKick').checked;
breakbeat.voiceMute.snare = document.getElementById('gameMuteSnare').checked;
breakbeat.voiceMute.hihat = document.getElementById('gameMuteHihat').checked;
// Melodic
breakbeat.voiceMute.bass = document.getElementById('gameMuteBass').checked;
breakbeat.voiceMute.pad = document.getElementById('gameMutePad').checked;
// Individual lead voices
breakbeat.voiceMute.xsidLead = document.getElementById('gameMuteSidLead').checked;
breakbeat.voiceMute.chipLead = document.getElementById('gameMuteChipLead').checked;
breakbeat.voiceMute.xpoly = document.getElementById('gameMuteXpoly').checked;
breakbeat.voiceMute.xwave = document.getElementById('gameMuteXwave').checked;
breakbeat.voiceMute.fmWhammy = document.getElementById('gameMuteFmWhammy').checked;
breakbeat.voiceMute.fmBrass = document.getElementById('gameMuteFmBrass').checked;
breakbeat.voiceMute.fmPad = document.getElementById('gameMuteFmPad').checked;
}
function updateDrumMindSettings() {
const ghostProb = parseInt(document.getElementById('gameGhostProb').value) || 40;
const evolveRate = parseInt(document.getElementById('gameEvolveRate').value) || 30;
musicSettings.ghostProb = ghostProb;
musicSettings.evolveRate = evolveRate;
document.getElementById('gameGhostProbVal').textContent = ghostProb + '%';
document.getElementById('gameEvolveRateVal').textContent = evolveRate + '%';
// Apply to sequencer globals too
seqGhostProb = ghostProb;
seqEvolveRate = evolveRate;
}
function updateEmergentMelodySettings() {
const motifChance = parseInt(document.getElementById('gameMotifChance').value) || 55;
const callResponse = parseInt(document.getElementById('gameCallResponse').value) || 65;
musicSettings.motifChance = motifChance;
musicSettings.callResponse = callResponse;
document.getElementById('gameMotifChanceVal').textContent = motifChance + '%';
document.getElementById('gameCallResponseVal').textContent = callResponse + '%';
}
function populateKitPool() {
const container = document.getElementById('kitPoolCheckboxes');
if (!container) return;
const kits = getAvailableKits();
if (enabledKits === null) enabledKits = kits.map(k => k.name); // Default all enabled
container.innerHTML = kits.map((kit, i) => {
const shortName = kit.name.replace('ARCADE ', '').substring(0, 12);
const checked = enabledKits.includes(kit.name) ? 'checked' : '';
return `<label style="color:#8F8;cursor:pointer;white-space:nowrap;"><input type="checkbox" ${checked} onchange="toggleKitEnabled('${kit.name}')" style="margin-right:1px;">${shortName}</label>`;
}).join('');
}
function toggleKitEnabled(kitName) {
if (enabledKits === null) enabledKits = getAvailableKits().map(k => k.name);
const idx = enabledKits.indexOf(kitName);
if (idx >= 0) {
enabledKits.splice(idx, 1);
} else {
enabledKits.push(kitName);
}
// Ensure at least one kit is enabled
if (enabledKits.length === 0) {
enabledKits.push(kitName);
populateKitPool(); // Re-render
}
}
function selectAllKits(selectAll) {
const kits = getAvailableKits();
enabledKits = selectAll ? kits.map(k => k.name) : [kits[0].name]; // At least one
populateKitPool();
}
// WINAMP PLAYER LOGIC
let winampKits = [];
let winampIndex = 0;
function initWinampPlayer() {
winampKits = getAvailableKits();
// Try to find current kit name match
if (winampKits.length > 0) {
const currentName = breakbeat && breakbeat.currentKitName;
const idx = winampKits.findIndex(k => k.name === currentName);
winampIndex = idx >= 0 ? idx : 0;
}
updateWinampDisplay();
updateWinampPlayButton();
}
// Alias for existing init calls
const populateKitPreviewSelect = initWinampPlayer;
function updateWinampDisplay() {
const display = document.getElementById('winamp-display');
const kbps = document.getElementById('winamp-kbps');
if (!display || winampKits.length === 0) {
if (display) display.textContent = "NO KITS LOADED";
return;
}
const kit = winampKits[winampIndex];
const idxStr = (winampIndex + 1).toString().padStart(2, '0');
// Marquee-style text padding
const text = `${idxStr}. ${kit.name} (${kit.bpm} BPM) *** ${kit.name} ***`;
display.textContent = text;
if (kbps) {
// Fake bitrate for vibe
const rates = [128, 160, 192, 320];
kbps.textContent = rates[(kit.name.length + winampIndex) % rates.length];
}
}
function winampNext() {
if (winampKits.length === 0) return;
winampIndex = (winampIndex + 1) % winampKits.length;
updateWinampDisplay();
if (kitPreviewMode) {
previewRawKit(winampKits[winampIndex].name);
}
}
function winampPrev() {
if (winampKits.length === 0) return;
winampIndex = (winampIndex - 1 + winampKits.length) % winampKits.length;
updateWinampDisplay();
if (kitPreviewMode) {
previewRawKit(winampKits[winampIndex].name);
}
}
function winampShuffle() {
if (winampKits.length === 0) return;
winampIndex = Math.floor(Math.random() * winampKits.length);
updateWinampDisplay();
if (kitPreviewMode) {
previewRawKit(winampKits[winampIndex].name);
}
}
function toggleKitPlay() {
// Toggle play/stop state
if (kitPreviewMode && breakbeat.isPlaying) {
// Stop
kitPreviewMode = false;
breakbeat.previewMode = false;
stopBreakbeat();
updateWinampPlayButton();
} else {
// Play
if (winampKits.length === 0) return;
kitPreviewMode = true;
// Note: previewRawKit will handle starting audio
previewRawKit(winampKits[winampIndex].name);
updateWinampPlayButton();
}
}
function updateWinampPlayButton() {
const btn = document.getElementById('winamp-play-btn');
if (!btn) return;
// If playing AND in preview mode
if (breakbeat && breakbeat.isPlaying && kitPreviewMode) {
btn.textContent = '⏹';
btn.style.color = '#F44';
} else {
btn.textContent = '▶';
btn.style.color = '#0F0';
}
}
function cancelKitPreview() {
// Legacy support if called elsewhere
if (kitPreviewMode) toggleKitPlay();
}
// Kit preview settings
let kitPreviewBpmLocked = false;
let kitPreviewApplySettings = false;
let kitPreviewOriginalState = null;
function togglePreviewBpmLock() {
kitPreviewBpmLocked = !kitPreviewBpmLocked;
const btn = document.getElementById('kitPreviewBpmLock');
if (kitPreviewBpmLocked) {
btn.textContent = '🔒';
btn.style.borderColor = '#FA0';
btn.style.color = '#FA0';
} else {
btn.textContent = '🔓';
btn.style.borderColor = '#666';
btn.style.color = '#666';
}
}
function togglePreviewApplySettings() {
kitPreviewApplySettings = !kitPreviewApplySettings;
const btn = document.getElementById('kitPreviewApplySettings');
if (kitPreviewApplySettings) {
btn.textContent = '⚙️ Wet';
btn.style.borderColor = '#0F0';
btn.style.color = '#0F0';
btn.style.background = '#0F02';
} else {
btn.textContent = '⚙️ Dry';
btn.style.borderColor = '#666';
btn.style.color = '#666';
btn.style.background = '#222';
}
}
function onPreviewKitChange() {
// No-op: Functionality replaced by Winamp player logic
}
function previewRawKit(kitName) {
// Play kit patterns - raw or with settings based on toggle
const kits = getAvailableKits();
const kit = kits.find(k => k.name === kitName);
if (!kit) return;
// Store original state to restore later (only if starting fresh preview session)
if (!kitPreviewOriginalState && !kitPreviewMode) {
kitPreviewOriginalState = {
patterns: breakbeat.patterns ? JSON.parse(JSON.stringify(breakbeat.patterns)) : null,
bpm: breakbeat.bpm,
kitName: breakbeat.currentKitName
};
}
// Set preview mode based on Apply Settings toggle
// If Apply Settings is ON, we allow evolution; if OFF, we block it
breakbeat.previewMode = !kitPreviewApplySettings;
// Ensure patterns object exists
if (!breakbeat.patterns) {
breakbeat.patterns = {
kick: new Array(16).fill(0),
snare: new Array(16).fill(0),
hihat: new Array(16).fill(0),
bass: new Array(16).fill(0),
lead: new Array(16).fill(0)
};
}
// Apply kit patterns directly (HOT SWAP)
if (kit.kick) breakbeat.patterns.kick = [...kit.kick];
if (kit.snare) breakbeat.patterns.snare = [...kit.snare];
if (kit.noise) breakbeat.patterns.hihat = [...kit.noise];
if (kit.bass) breakbeat.patterns.bass = [...kit.bass];
if (kit.lead) breakbeat.patterns.lead = [...kit.lead];
// Also update ride/crash if kit has them (future proofing)
if (kit.ride && breakbeat.patterns.ride) breakbeat.patterns.ride = [...kit.ride];
breakbeat.currentKitName = kit.name;
// BPM: use kit's BPM unless locked (then keep current)
if (!kitPreviewBpmLocked) {
breakbeat.bpm = kit.bpm;
} else {
// When locked, use current BPM or seqBpm or kit's BPM as fallback
breakbeat.bpm = breakbeat.bpm || seqBpm || kit.bpm;
}
// Clear bar instincts if in raw mode
if (!kitPreviewApplySettings) {
if (!breakbeat.barInstinct) breakbeat.barInstinct = {};
breakbeat.barInstinct.lockBarsRemaining = 0;
breakbeat.barInstinct.chorusOn = false;
breakbeat.barInstinct.backbeatOn = false;
breakbeat.barInstinct.bassStepOffset = 0;
breakbeat.barInstinct.harmonyMode = 'none';
breakbeat.barInstinct.pruneMult = {};
}
// Start playing manually if not already playing
if (!breakbeat.isPlaying) {
initAudio();
breakbeat.isPlaying = true;
// Reset counters for fresh start
breakbeat.currentStep = 0;
breakbeat.barCount = 0;
breakbeat.nextNoteTime = audioCtx.currentTime;
scheduler();
}
// If already playing, we just leave it running. The scheduler will pick up the new patterns
// and BPM on the next tick/note. This provides the "Smooth Transition".
const mode = kitPreviewApplySettings ? 'WET' : 'DRY';
const bpmInfo = kitPreviewBpmLocked ? `${Math.round(breakbeat.bpm)}bpm 🔒` : `${kit.bpm}bpm`;
logEvolution(`PREVIEW ${mode}: ${kit.name} @ ${bpmInfo}`);
// Update main kit select to match just in case
const mainSelect = document.getElementById('musicKitSelect');
if (mainSelect) mainSelect.value = kitName;
}
// Visual Sequencer state - points directly to live patterns!
let seqPatterns = null; // Will reference breakbeat.patterns directly
let seqBpm = 90;
let seqKitName = '';
let seqKitPhrase = 32; // Default to 32 (matches HTML selected option)
let seqScale = 'minor';
let seqMotifVoice = '';
let seqMotifBars = 4;
let seqBassStyle = 'classic'; // 'classic' or 'tuss' (legacy, kept for compatibility)
let seqHatMotif = {}; // Per-voice hat motif: { 'hat0': '808', 'hat1': '606', ... }
let seqBassMotif = {}; // Per-voice bass motif: { 'bass0': 'classic', 'bass1': 'tuss', ... }
// Per-voice gain values (0-200, default 100)
const motifVoiceGains = {
kick: 100,
snare: 100,
hihat: 100,
bass: 100,
pad: 100,
lead: 100
};
// Deep settings per voice type (defaults)
const kickDeepDefaults = { pitch: 55, decay: 150, punch: 50, drive: 20 };
const snareDeepDefaults = { tone: 200, snappy: 60, decay: 150, noise: 70 };
const hihatDeepDefaults = { closed: 50, open: 200, tone: 8000, ring: 30, mix: 5 };
const bassDeepDefaults = { style: 'classic', filter: 1200, res: 5, slide: 30, decay: 400 };
const padDeepDefaults = { attack: 70, release: 800, filter: 2000, reso: 5, detune: 10, voices: 4 };
// Current deep settings (start with defaults)
const kickDeepSettings = { ...kickDeepDefaults };
const snareDeepSettings = { ...snareDeepDefaults };
const hihatDeepSettings = { ...hihatDeepDefaults };
const bassDeepSettings = { ...bassDeepDefaults };
const padDeepSettings = { ...padDeepDefaults };
let seqChaos = 30;
let seqRally = 50;
let seqBpmLocked = false;
let seqGhostMute = false; // Mute all algorithmic ghost patterns
let seqGhostProb = (() => {
try {
const v = parseInt(localStorage.getItem('seqGhostProb_v2') || '', 10);
return Number.isFinite(v) ? Math.max(0, Math.min(100, v)) : 60;
} catch (e) {
return 60;
}
})();
let seqEvolveRate = (() => {
try {
const v = parseInt(localStorage.getItem('seqEvolveRate_v2') || '', 10);
return Number.isFinite(v) ? Math.max(0, Math.min(100, v)) : 50;
} catch (e) {
return 50;
}
})();
let ghostTriggers = {}; // Track which ghost notes just triggered for flash effect
let seqUserPainted = {}; // Track cells explicitly painted by user (voiceType-step: true)
let seqGraduation = { active: false, startBar: 0, totalBars: 0, lastBar: -1, stage: 0, hits: {} };
let selectedSnapshotIdx = -1;
// === USER INTERACTION LOCK (prevent motif/pattern changes while editing) ===
let userIsEditing = false; // True when user is actively interacting
let userEditTimeout = null; // Timeout to clear editing flag after inactivity
const USER_EDIT_IDLE_TIME = 2500; // 2.5 seconds of inactivity before allowing changes
// === CHAPTER SYSTEM (like 1010seq scenes) ===
// 8 chapter slots - empty until filled, auto-save on switch
let seqChapters = new Array(8).fill(null);
let seqActiveChapter = -1; // -1 = no chapter active yet
let seqChapterCopyMode = false;
let seqChapterClearMode = false; // Clear mode for deleting chapters
let seqChapterBlendMode = false; // Blend mode for morphing chapters
let seqChapterPlaying = false; // Chapter progression playing
let seqChapterPhrase = 4; // Bars per chapter before advancing
let seqChapterBarCount = 0; // Current bar within phrase
let seqChapterLoop = []; // Which chapters to loop through (indices)
let seqShowAllVoices = false; // Toggle to show all voices vs first 6
const SEQ_DEFAULT_VISIBLE_VOICES = 6;
// Load chapters from localStorage on init
try {
const saved = localStorage.getItem('seqChapters_v1');
if (saved) {
const parsed = JSON.parse(saved);
if (Array.isArray(parsed) && parsed.length === 8) {
seqChapters = parsed;
// Auto-activate chapter 1 (or first chapter with data) on startup
seqActiveChapter = seqChapters[0] ? 0 : seqChapters.findIndex(c => c !== null);
if (seqActiveChapter < 0) seqActiveChapter = -1;
}
}
} catch (e) { /* ignore */ }
function saveChaptersToStorage() {
try {
localStorage.setItem('seqChapters_v1', JSON.stringify(seqChapters));
} catch (e) { /* ignore */ }
}
// === CHAPTER COLLECTIONS (save/load named sets of chapters) ===
const CUTE_STRINGS = {
starters: ["that-time-we", "when-we", "remember-when", "once-upon-a", "the-day-we", "our-first", "the-night-we", "one-magic", "a-little", "how-we", "that-one-day", "on-the-morning-we", "do-you-remember", "every-time-we", "the-afternoon-we", "this-is-when", "always-after", "the-dream-where-we", "i-swear-we", "we-almost", "just-before-we", "the-moment-we", "while-we", "the-story-of", "every-little-time", "somehow-we", "and-then-we", "if-you-remember", "before-we-forgot", "someday-when-we", "on-that-sunday-we", "the-last-time-we", "it-started-when", "in-a-dream-we", "so-many-times-we", "when-it-rained-and-we", "the-best-day-we", "one-sunny-morning-we", "right-after-we", "the-minute-we", "just-like-before", "the-wonder-of", "from-that-night-we"],
verbs: ["found-a", "lost-a", "made-a", "saw-a", "met-a", "heard-a", "painted-a", "shared-a", "cuddled-a", "built-a", "chased-a", "caught-a", "drew-a", "sang-about-a", "hugged-a", "wrote-about-a", "hid-a", "tickled-a", "wrapped-up-a", "discovered-a", "named-a", "whispered-about-a", "giggled-at-a", "skipped-with-a", "danced-with-a", "brought-home-a", "made-up-a", "bundled-up-a", "packed-a", "carried-a", "borrowed-a", "dreamed-of-a", "grinned-at-a", "high-fived-a", "napped-with-a", "saved-a", "invented-a", "befriended-a", "splashed-in-a"],
objects: ["bunny", "song", "secret", "fort", "blanket", "dance", "sparkle", "cloud", "cookie", "melody", "starry-sky", "dream", "feather", "pancake", "pillow", "wish", "whale", "daisy", "snowflake", "nightlight", "toy-drum", "jellybean", "fluffy-cat", "fuzzy-hat", "soft-note", "friend", "heart", "polka-dot", "firefly", "marshmallow", "lullaby", "pajama-party", "lost-balloon", "pocket-sandwich", "hiccup", "glowstick", "rainbow-crumb", "dream-boat", "cup-of-tea", "cardboard-robot", "paper-plane", "bubble-bath", "storybook", "muffin", "awesome-sticker", "paintbrush", "crayon-drawing", "stuffed-dolphin", "moon-rock", "secret-handshake", "tiny-umbrella", "warm-hug", "cozy-corner", "sleepy-bee", "paper-crown", "sock-puppet", "pillow-fort"],
extras: ["under-the-stars", "before-bed", "with-sparkles", "by-the-lake", "on-mars", "at-midnight", "forever", "in-pajamas", "with-hugs", "and-giggles", "on-our-pillow", "with-buttered-toast", "between-naps", "under-the-blanket", "in-our-dreams", "before-sunrise", "by-the-window", "with-hot-cocoa", "in-the-treehouse", "with-paper-stars", "during-a-thunderstorm", "inside-the-fort", "at-the-dream-cafe", "between-heartbeats", "with-pink-glitter", "under-the-pillow", "on-a-rainy-day", "inside-a-storybook", "with-extra-snuggles", "through-the-tulips", "in-the-moonlight", "with-crayon-fingers", "while-it-snowed", "in-our-rocket-ship", "during-naptime", "with-cotton-candy", "beside-the-fireflies", "wearing-capes", "on-our-scooters", "with-bunny-slippers", "while-the-kettle-whistled", "with-frosted-windows", "on-the-rooftop", "in-a-blanket-burrito", "while-we-were-laughing", "with-the-door-open", "in-our-cardboard-castle", "while-chasing-fireflies", "with-our-hats-on-backwards", "on-a-sunday-afternoon", "after-ice-cream", "in-matching-socks", "with-silly-voices", "on-a-lazy-morning"]
};
function generateCuteName() {
const pick = arr => arr[Math.floor(Math.random() * arr.length)];
return `${pick(CUTE_STRINGS.verbs)}-${pick(CUTE_STRINGS.objects)}-${pick(CUTE_STRINGS.extras)}`.replace(/-+/g, '-');
}
function saveChapterCollection(name) {
if (!name) name = generateCuteName();
saveCurrentChapter(); // Ensure latest changes are captured
const collection = {
chapters: seqChapters,
activeChapter: seqActiveChapter,
savedAt: Date.now()
};
localStorage.setItem(`seqCollection_${name}`, JSON.stringify(collection));
logEvolution(`SAVED: ${name}`);
renderChapterCollectionUI();
return name;
}
function loadChapterCollection(name) {
const data = localStorage.getItem(`seqCollection_${name}`);
if (!data) { logEvolution('NOT FOUND'); return; }
const collection = JSON.parse(data);
seqChapters = collection.chapters || new Array(8).fill(null);
// Always start at chapter 1 (or first chapter with data)
seqActiveChapter = seqChapters[0] ? 0 : seqChapters.findIndex(c => c !== null);
if (seqActiveChapter < 0) seqActiveChapter = -1;
seqChapterLoop = [];
seqChapterPlaying = false;
saveChaptersToStorage();
if (seqActiveChapter >= 0 && seqChapters[seqActiveChapter]) {
loadChapterState(seqChapters[seqActiveChapter]);
}
renderChapterButtons();
renderSeqCanvas();
renderDriftControls();
// Set the name input to the loaded collection name
const nameInput = document.getElementById('chapterCollectionName');
if (nameInput) {
nameInput.value = name;
nameInput.style.color = '#0FF';
}
logEvolution(`LOADED: ${name}`);
}
function deleteChapterCollection(name) {
if (!name) { logEvolution('SELECT A COLLECTION'); return; }
// Show confirmation modal
showDeleteConfirmModal(name, () => {
localStorage.removeItem(`seqCollection_${name}`);
renderChapterCollectionUI();
logEvolution(`DELETED: ${name}`);
});
}
// Confirmation modal for delete (doesn't stop music)
function showDeleteConfirmModal(name, onConfirm) {
// Create modal overlay
const overlay = document.createElement('div');
overlay.id = 'deleteConfirmOverlay';
overlay.style.cssText = 'position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,0.8);z-index:300;display:flex;align-items:center;justify-content:center;';
const modal = document.createElement('div');
modal.style.cssText = 'background:#111;border:2px solid #F44;padding:20px;max-width:320px;text-align:center;font-family:"IBM Plex Mono",monospace;';
modal.innerHTML = `
<div style="color:#F44;font-size:14px;margin-bottom:12px;">⚠️ DELETE COLLECTION?</div>
<div style="color:#FFF;font-size:11px;margin-bottom:16px;word-break:break-all;">${name}</div>
<div style="display:flex;gap:8px;justify-content:center;">
<button id="deleteConfirmYes" style="padding:8px 16px;background:#F44;border:none;color:#FFF;font-size:11px;cursor:pointer;">DELETE</button>
<button id="deleteConfirmNo" style="padding:8px 16px;background:#333;border:1px solid #666;color:#FFF;font-size:11px;cursor:pointer;">CANCEL</button>
</div>
`;
overlay.appendChild(modal);
document.body.appendChild(overlay);
document.getElementById('deleteConfirmYes').onclick = () => {
overlay.remove();
onConfirm();
};
document.getElementById('deleteConfirmNo').onclick = () => {
overlay.remove();
};
overlay.onclick = (e) => {
if (e.target === overlay) overlay.remove();
};
}
function exportChapterCollection(name) {
if (!name) { logEvolution('SELECT A COLLECTION'); return; }
const data = localStorage.getItem(`seqCollection_${name}`);
if (!data) { logEvolution('COLLECTION NOT FOUND'); return; }
const blob = new Blob([data], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${name}.json`;
a.click();
URL.revokeObjectURL(url);
logEvolution(`EXPORTED: ${name}.json`);
}
function importChapterCollection(file) {
const reader = new FileReader();
reader.onload = (e) => {
try {
const data = JSON.parse(e.target.result);
const name = file.name.replace(/\.json$/i, '');
localStorage.setItem(`seqCollection_${name}`, JSON.stringify(data));
renderChapterCollectionUI();
logEvolution(`IMPORTED: ${name}`);
} catch (err) {
logEvolution('IMPORT FAILED: Invalid JSON');
}
};
reader.readAsText(file);
}
// Drag-and-drop: import AND load collection, open sequencer if needed
function handleCollectionDrop(file) {
if (!file || !file.name.toLowerCase().endsWith('.json')) {
logEvolution('DROP FAILED: Not a JSON file');
return;
}
const reader = new FileReader();
reader.onload = (e) => {
try {
const data = JSON.parse(e.target.result);
const name = file.name.replace(/\.json$/i, '');
// Import to localStorage
localStorage.setItem(`seqCollection_${name}`, JSON.stringify(data));
renderChapterCollectionUI();
// Open sequencer if not already open
const overlay = document.getElementById('sequencerOverlay');
if (!overlay || overlay.style.display === 'none') {
openSequencer();
}
// Load the collection
loadChapterCollection(name);
logEvolution(`LOADED: ${name}`);
} catch (err) {
logEvolution('DROP FAILED: Invalid JSON - ' + err.message);
}
};
reader.readAsText(file);
}
function getChapterCollections() {
const collections = [];
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i);
if (key && key.startsWith('seqCollection_')) {
const name = key.replace('seqCollection_', '');
try {
const data = JSON.parse(localStorage.getItem(key));
collections.push({ name, savedAt: data.savedAt || 0 });
} catch (e) { /* ignore */ }
}
}
return collections.sort((a, b) => b.savedAt - a.savedAt);
}
function clearAllChapters() {
seqChapters = new Array(8).fill(null);
seqActiveChapter = -1;
seqChapterLoop = [];
seqChapterPlaying = false;
saveChaptersToStorage();
renderChapterButtons();
logEvolution('CHAPTERS CLEARED');
}
function clearAllChapterStorage() {
// Clear only chapter-related localStorage keys for this app
const keysToRemove = [];
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i);
if (key && (key.startsWith('seqCollection_') || key === 'seqChapters_v1')) {
keysToRemove.push(key);
}
}
keysToRemove.forEach(k => localStorage.removeItem(k));
clearAllChapters();
renderChapterCollectionUI();
logEvolution(`CLEARED ${keysToRemove.length} STORAGE KEYS`);
}
function renderChapterCollectionUI() {
// Populate the collection dropdown (UI is now in HTML)
const select = document.getElementById('chapterCollectionSelect');
if (!select) return;
const collections = getChapterCollections();
let html = `<option value="">-- Select --</option>`;
collections.forEach(c => {
html += `<option value="${c.name}">${c.name}</option>`;
});
select.innerHTML = html;
// Set suggested name if input is empty
const nameInput = document.getElementById('chapterCollectionName');
if (nameInput && !nameInput.value) {
nameInput.value = generateCuteName();
}
}
function captureChapterState() {
const patternsSnapshot = {};
if (seqPatterns) {
for (const key of Object.keys(seqPatterns)) {
if (Array.isArray(seqPatterns[key])) {
patternsSnapshot[key] = [...seqPatterns[key]];
}
}
}
return {
patterns: patternsSnapshot,
userPainted: { ...seqUserPainted },
voiceList: voiceList.map(v => ({ ...v })),
bpm: seqBpm,
bpmLocked: seqBpmLocked,
scale: seqScale,
ghostMute: seqGhostMute,
ghostProb: seqGhostProb,
drift: { ...seqDrift },
driftSpeed: { ...seqDriftSpeed },
volume: { ...seqVolume },
mute: { ...seqMute },
solo: { ...seqSolo },
// Sliders
chaos: seqChaos,
rally: seqRally,
evolveRate: seqEvolveRate,
masterGain: globalMasterGain * 100, // Convert back to 0-200 range
// Kit and motif settings
kitName: seqKitName,
kitPhrase: seqKitPhrase,
motifVoice: seqMotifVoice,
motifBars: seqMotifBars,
// Deep settings
leadVoiceSettings: JSON.parse(JSON.stringify(leadVoiceSettings)), // Deep copy
kickDeepSettings: JSON.parse(JSON.stringify(kickDeepSettings)),
snareDeepSettings: JSON.parse(JSON.stringify(snareDeepSettings)),
hihatDeepSettings: JSON.parse(JSON.stringify(hihatDeepSettings)),
bassDeepSettings: JSON.parse(JSON.stringify(bassDeepSettings)),
padDeepSettings: JSON.parse(JSON.stringify(padDeepSettings))
};
}
function saveCurrentChapter() {
if (seqActiveChapter < 0 || seqActiveChapter >= 8) return;
seqChapters[seqActiveChapter] = captureChapterState();
// Defer storage to avoid blocking chapter progression
setTimeout(() => saveChaptersToStorage(), 0);
}
function loadChapterState(chapter) {
if (!chapter) return;
// NOTE: This function is backward compatible with old collection formats.
// Missing fields are safely skipped and current defaults are used.
if (chapter.patterns && seqPatterns) {
for (const key of Object.keys(chapter.patterns)) {
seqPatterns[key] = [...chapter.patterns[key]];
}
}
seqUserPainted = { ...chapter.userPainted };
if (chapter.voiceList && Array.isArray(chapter.voiceList)) {
voiceList.length = 0;
chapter.voiceList.forEach(v => voiceList.push({ ...v }));
}
// BPM - restore locked state first, then value
if (typeof chapter.bpmLocked === 'boolean') {
seqBpmLocked = chapter.bpmLocked;
// Update UI
const lockBtn = document.getElementById('seqBpmLockBtn');
if (lockBtn) {
lockBtn.textContent = seqBpmLocked ? '🔒' : '🔓';
lockBtn.style.borderColor = seqBpmLocked ? '#F0F' : '#555';
lockBtn.style.color = seqBpmLocked ? '#F0F' : '#888';
}
}
if (chapter.bpm) {
if (!seqBpmLocked) seqBpm = chapter.bpm;
// Update UI slider
const bpmSlider = document.getElementById('seqBpm');
if (bpmSlider) bpmSlider.value = chapter.bpm;
}
if (chapter.scale) {
seqScale = chapter.scale;
const scaleSelect = document.getElementById('seqScale');
if (scaleSelect) scaleSelect.value = chapter.scale;
}
if (typeof chapter.ghostMute === 'boolean') {
seqGhostMute = chapter.ghostMute;
const ghostMuteBtn = document.getElementById('seqGhostMuteBtn');
if (ghostMuteBtn) ghostMuteBtn.textContent = seqGhostMute ? 'GHOST OFF' : 'GHOST ON';
}
if (typeof chapter.ghostProb === 'number') {
seqGhostProb = chapter.ghostProb;
const ghostProbEl = document.getElementById('seqGhostProb');
if (ghostProbEl) ghostProbEl.value = seqGhostProb;
}
if (chapter.drift) Object.assign(seqDrift, chapter.drift);
if (chapter.driftSpeed) Object.assign(seqDriftSpeed, chapter.driftSpeed);
if (chapter.volume) Object.assign(seqVolume, chapter.volume);
if (chapter.mute) Object.assign(seqMute, chapter.mute);
if (chapter.solo) Object.assign(seqSolo, chapter.solo);
// Sliders
if (typeof chapter.chaos === 'number') {
seqChaos = chapter.chaos;
const chaosEl = document.getElementById('seqChaos');
if (chaosEl) chaosEl.value = seqChaos;
}
if (typeof chapter.rally === 'number') {
seqRally = chapter.rally;
const rallyEl = document.getElementById('seqRally');
if (rallyEl) rallyEl.value = seqRally;
}
if (typeof chapter.evolveRate === 'number') {
seqEvolveRate = chapter.evolveRate;
const evolveEl = document.getElementById('seqEvolveRate');
if (evolveEl) {
evolveEl.value = seqEvolveRate;
setEvolveRate(seqEvolveRate);
}
}
if (typeof chapter.masterGain === 'number') {
setMasterGain(chapter.masterGain);
}
// Kit and motif settings
if (chapter.kitName !== undefined) {
seqKitName = chapter.kitName;
const kitSelect = document.getElementById('seqKitSelect');
if (kitSelect) kitSelect.value = seqKitName;
// Apply kit if it exists
if (seqKitName) applySeqSetting('kit', seqKitName);
}
if (typeof chapter.kitPhrase === 'number') {
seqKitPhrase = chapter.kitPhrase;
const kitPhraseSelect = document.getElementById('seqKitPhrase');
if (kitPhraseSelect) kitPhraseSelect.value = seqKitPhrase;
applySeqSetting('kitPhrase', seqKitPhrase);
}
if (chapter.motifVoice !== undefined) {
seqMotifVoice = chapter.motifVoice;
const motifSelect = document.getElementById('seqMotifVoice');
if (motifSelect) motifSelect.value = seqMotifVoice;
applySeqSetting('motif', seqMotifVoice);
}
if (typeof chapter.motifBars === 'number') {
seqMotifBars = chapter.motifBars;
const motifBarsSelect = document.getElementById('seqMotifBars');
if (motifBarsSelect) motifBarsSelect.value = seqMotifBars;
applySeqSetting('bars', seqMotifBars);
}
// Deep settings
if (chapter.leadVoiceSettings) {
Object.assign(leadVoiceSettings, chapter.leadVoiceSettings);
// Sync lead deep UI (same as openLeadVoiceSettings but without opening modal)
if (document.getElementById('lvAttack')) {
document.getElementById('lvAttack').value = leadVoiceSettings.attack;
document.getElementById('lvAtkVal').textContent = leadVoiceSettings.attack + 'ms';
document.getElementById('lvDecay').value = leadVoiceSettings.decay;
document.getElementById('lvDecVal').textContent = leadVoiceSettings.decay + 'ms';
document.getElementById('lvSustain').value = leadVoiceSettings.sustain;
document.getElementById('lvSusVal').textContent = leadVoiceSettings.sustain + '%';
document.getElementById('lvRelease').value = leadVoiceSettings.release;
document.getElementById('lvRelVal').textContent = leadVoiceSettings.release + 'ms';
document.getElementById('lvFilterCut').value = leadVoiceSettings.filterCut;
document.getElementById('lvCutVal').textContent = (leadVoiceSettings.filterCut / 1000).toFixed(1) + 'k';
document.getElementById('lvFilterRes').value = leadVoiceSettings.filterRes;
document.getElementById('lvResVal').textContent = leadVoiceSettings.filterRes;
document.getElementById('lvFilterType').value = leadVoiceSettings.filterType;
document.getElementById('lvVibRate').value = leadVoiceSettings.vibRate;
document.getElementById('lvVibRateVal').textContent = leadVoiceSettings.vibRate + 'Hz';
document.getElementById('lvVibDepth').value = leadVoiceSettings.vibDepth;
document.getElementById('lvVibDepthVal').textContent = leadVoiceSettings.vibDepth + '%';
document.getElementById('lvVibDelay').value = leadVoiceSettings.vibDelay;
document.getElementById('lvVibDelayVal').textContent = leadVoiceSettings.vibDelay + 'ms';
document.getElementById('lvVolume').value = leadVoiceSettings.volume;
document.getElementById('lvVolVal').textContent = leadVoiceSettings.volume + '%';
document.getElementById('lvPan').value = leadVoiceSettings.pan;
document.getElementById('lvPanVal').textContent = leadVoiceSettings.pan === 0 ? 'C' : (leadVoiceSettings.pan > 0 ? 'R' : 'L') + Math.abs(leadVoiceSettings.pan);
document.getElementById('lvNormalize').checked = leadVoiceSettings.normalize;
document.getElementById('lvOctave').value = leadVoiceSettings.octave;
const octSign = leadVoiceSettings.octave >= 0 ? '+' : '';
document.getElementById('lvOctVal').textContent = octSign + parseFloat(leadVoiceSettings.octave).toFixed(2);
document.getElementById('lvDetune').value = leadVoiceSettings.detune;
document.getElementById('lvDetuneVal').textContent = leadVoiceSettings.detune + 'c';
document.getElementById('lvDrift').value = leadVoiceSettings.drift || 0;
document.getElementById('lvDriftVal').textContent = (leadVoiceSettings.drift || 0) + 'c';
document.getElementById('lvGlide').value = leadVoiceSettings.glide;
document.getElementById('lvGlideVal').textContent = leadVoiceSettings.glide + 'ms';
document.getElementById('lvLfoFilter').value = leadVoiceSettings.lfoFilter;
document.getElementById('lvLfoFilterVal').textContent = leadVoiceSettings.lfoFilter + '%';
document.getElementById('lvLfoRate').value = leadVoiceSettings.lfoRate;
document.getElementById('lvLfoRateVal').textContent = leadVoiceSettings.lfoRate + 'Hz';
document.getElementById('lvStereoWidth').value = leadVoiceSettings.stereoWidth;
document.getElementById('lvStereoVal').textContent = leadVoiceSettings.stereoWidth + '%';
document.getElementById('lvDelaySend').value = leadVoiceSettings.delaySend;
document.getElementById('lvDelayVal').textContent = leadVoiceSettings.delaySend + '%';
document.getElementById('lvReverbSend').value = leadVoiceSettings.reverbSend;
document.getElementById('lvReverbVal').textContent = leadVoiceSettings.reverbSend + '%';
document.getElementById('lvDrive').value = leadVoiceSettings.drive;
document.getElementById('lvDriveVal').textContent = leadVoiceSettings.drive + '%';
document.getElementById('lvChorus').value = leadVoiceSettings.chorus;
document.getElementById('lvChorusVal').textContent = leadVoiceSettings.chorus + '%';
document.getElementById('lvBitCrush').value = leadVoiceSettings.bitCrush;
document.getElementById('lvBitVal').textContent = leadVoiceSettings.bitCrush + '%';
document.getElementById('lvSampleRate').value = leadVoiceSettings.sampleRate;
document.getElementById('lvSrVal').textContent = leadVoiceSettings.sampleRate + '%';
}
// Sync lead gain slider
const leadGainSlider = document.getElementById('leadMotifGain');
const leadGainVal = document.getElementById('leadMotifGainVal');
if (leadGainSlider) leadGainSlider.value = leadVoiceSettings.volume || 100;
if (leadGainVal) leadGainVal.textContent = (leadVoiceSettings.volume || 100) + '%';
syncLeadUniversalControls();
}
if (chapter.kickDeepSettings) {
Object.assign(kickDeepSettings, chapter.kickDeepSettings);
syncKickDeepUI();
syncVoiceQuickControls('kick');
}
if (chapter.snareDeepSettings) {
Object.assign(snareDeepSettings, chapter.snareDeepSettings);
syncSnareDeepUI();
syncVoiceQuickControls('snare');
}
if (chapter.hihatDeepSettings) {
Object.assign(hihatDeepSettings, chapter.hihatDeepSettings);
syncHihatDeepUI();
syncVoiceQuickControls('hihat');
}
if (chapter.bassDeepSettings) {
Object.assign(bassDeepSettings, chapter.bassDeepSettings);
syncBassDeepUI();
syncVoiceQuickControls('bass');
}
if (chapter.padDeepSettings) {
Object.assign(padDeepSettings, chapter.padDeepSettings);
syncPadDeepUI();
syncVoiceQuickControls('pad');
}
}
function switchChapter(idx, forceReload = false) {
if (idx < 0 || idx >= 8) return;
// If clicking empty slot, save current pattern there
if (!seqChapters[idx]) {
seqChapters[idx] = captureChapterState();
seqActiveChapter = idx;
saveChaptersToStorage();
renderChapterButtons();
logEvolution(`SAVED ${idx + 1}`);
return;
}
// Don't switch to same chapter (unless forced reload for looping)
if (idx === seqActiveChapter && !forceReload) return;
// Save current before switching (if we have an active chapter)
if (seqActiveChapter >= 0) {
saveCurrentChapter();
}
// Switch - ALWAYS load the chapter state (this is the music, it must play!)
seqActiveChapter = idx;
loadChapterState(seqChapters[idx]);
// Always update UI - let users interact even while chapters are playing
renderChapterButtons();
renderSeqCanvas();
renderDriftControls();
logEvolution(`CHAPTER ${idx + 1}`);
}
function toggleChapterInLoop(idx) {
const pos = seqChapterLoop.indexOf(idx);
if (pos >= 0) {
seqChapterLoop.splice(pos, 1);
} else if (seqChapters[idx]) {
seqChapterLoop.push(idx);
seqChapterLoop.sort((a, b) => a - b);
}
renderChapterButtons();
}
function toggleChapterProgression() {
// Always loop through ALL filled chapters (simpler UX)
const filled = seqChapters.map((c, i) => c !== null ? i : -1).filter(i => i >= 0);
if (filled.length < 1) {
logEvolution('SAVE CHAPTERS FIRST');
return;
}
seqChapterLoop = filled;
seqChapterPlaying = !seqChapterPlaying;
seqChapterBarCount = 0;
renderChapterButtons();
logEvolution(seqChapterPlaying ? `LOOP: ${seqChapterLoop.map(i => i + 1).join('→')}` : 'LOOP STOPPED');
}
function advanceChapterOnBar() {
if (!seqChapterPlaying || seqChapterLoop.length < 1) return;
seqChapterBarCount++;
if (seqChapterBarCount >= seqChapterPhrase) {
seqChapterBarCount = 0;
// Find next chapter in loop - ALWAYS advance, no exceptions
let currentPos = seqChapterLoop.indexOf(seqActiveChapter);
// If current chapter isn't in loop, start from first chapter in loop
if (currentPos < 0) {
if (seqChapterLoop.length > 0 && seqChapters[seqChapterLoop[0]]) {
switchChapter(seqChapterLoop[0], false);
return;
}
return; // No valid chapters in loop
}
// Calculate next position in loop (wraps around)
const nextPos = (currentPos + 1) % seqChapterLoop.length;
const nextIdx = seqChapterLoop[nextPos];
// Always switch to next chapter (even if looping back to same one, it resets state)
if (nextIdx >= 0 && nextIdx < 8 && seqChapters[nextIdx]) {
const forceReload = (nextIdx === seqActiveChapter);
switchChapter(nextIdx, forceReload);
}
}
// Always update UI
renderChapterButtons();
}
function setChapterPhrase(val) {
const num = parseInt(val, 10);
if (num > 0 && num <= 999) {
seqChapterPhrase = num;
renderChapterButtons();
}
}
function renderChapterButtons() {
const container = document.getElementById('chapterButtonsContainer');
if (!container) return;
container.innerHTML = '';
// Play/Stop button with pulse when playing
const playBtn = document.createElement('button');
playBtn.textContent = seqChapterPlaying ? '■' : '▶';
playBtn.style.cssText = `
width: 24px; height: 28px; padding: 0; margin-right: 4px;
background: ${seqChapterPlaying ? '#0F0' : '#222'};
border: 2px solid ${seqChapterPlaying ? '#0F0' : '#555'};
color: ${seqChapterPlaying ? '#000' : '#0F0'};
font-size: 12px; font-weight: bold; cursor: pointer;
font-family: 'IBM Plex Mono', monospace;
vertical-align: top;
${seqChapterPlaying ? 'animation: chapterPulse 0.5s ease-in-out infinite;' : ''}
`;
playBtn.onclick = toggleChapterProgression;
playBtn.title = seqChapterPlaying ? 'Stop chapter loop' : 'Play chapter loop';
container.appendChild(playBtn);
// Chapter buttons
for (let i = 0; i < 8; i++) {
const btn = document.createElement('button');
btn.textContent = i + 1;
btn.className = 'chapter-btn';
const hasData = seqChapters[i] !== null;
const isActive = seqActiveChapter === i;
const isCopyTarget = seqChapterCopyMode && hasData && i !== seqActiveChapter;
const isClearTarget = seqChapterClearMode && hasData;
const isBlendTarget = seqChapterBlendMode && hasData && i !== seqActiveChapter;
let bg = '#222';
let border = '#444';
let color = '#444';
if (hasData) { bg = '#333'; border = '#666'; color = '#FFF'; }
if (isActive) { bg = '#0FF'; border = '#0FF'; color = '#000'; }
if (isCopyTarget) { bg = '#F0F'; border = '#F0F'; color = '#000'; }
if (isClearTarget) { bg = '#F44'; border = '#F44'; color = '#000'; }
if (isBlendTarget) { bg = '#FA0'; border = '#FA0'; color = '#000'; }
btn.style.cssText = `
width: 28px; height: 28px; padding: 0;
background: ${bg}; border: 2px solid ${border}; color: ${color};
font-size: 11px; font-weight: bold; cursor: pointer;
font-family: 'IBM Plex Mono', monospace; transition: all 0.15s;
vertical-align: top;
${isActive ? 'box-shadow: 0 0 8px #0FF, 0 0 12px #0FF;' : ''}
${isCopyTarget || isClearTarget || isBlendTarget ? 'animation: blink 0.4s infinite;' : ''}
`;
btn.onclick = (e) => {
if (seqChapterClearMode && hasData) {
seqChapters[i] = null;
seqChapterLoop = seqChapterLoop.filter(idx => idx !== i);
if (seqActiveChapter === i) seqActiveChapter = -1;
saveChaptersToStorage();
renderChapterButtons();
logEvolution('CLEARED ' + (i + 1));
return;
}
if (seqChapterCopyMode && hasData && i !== seqActiveChapter) {
seqChapters[i] = captureChapterState();
saveChaptersToStorage();
renderChapterButtons();
logEvolution('COPIED → ' + (i + 1));
return;
}
if (seqChapterBlendMode && hasData && i !== seqActiveChapter) {
// Blend current active chapter into this target slot
const source = captureChapterState();
const target = seqChapters[i];
if (target && source) {
const blended = { ...target };
// Blend patterns step-wise: choose step from source or target (50/50)
if (target.patterns && source.patterns) {
blended.patterns = {};
const keys = new Set([
...Object.keys(target.patterns || {}),
...Object.keys(source.patterns || {})
]);
keys.forEach((key) => {
const a = target.patterns[key] || [];
const b = source.patterns[key] || [];
const maxLen = Math.max(a.length, b.length);
const out = new Array(maxLen).fill(0);
for (let s = 0; s < maxLen; s++) {
const va = a[s] || 0;
const vb = b[s] || 0;
if (va && vb) {
// both have notes – randomly pick one
out[s] = Math.random() < 0.5 ? va : vb;
} else if (va || vb) {
// only one has a note – keep it with 80% chance
out[s] = Math.random() < 0.8 ? (va || vb) : 0;
} else {
out[s] = 0;
}
}
blended.patterns[key] = out;
});
}
// Merge userPainted flags (union)
if (target.userPainted || source.userPainted) {
blended.userPainted = { ...(target.userPainted || {}), ...(source.userPainted || {}) };
}
// Keep target's bpm/scale/voiceList etc so blend is mainly rhythmic/melodic
seqChapters[i] = blended;
saveChaptersToStorage();
renderChapterButtons();
logEvolution('BLEND → ' + (i + 1));
return;
}
}
switchChapter(i);
};
let title = 'Click: Save here';
if (hasData) {
if (isClearTarget) title = 'Click to clear';
else if (isCopyTarget) title = 'Copy here';
else if (isBlendTarget) title = 'Blend current into here';
else if (isActive) title = 'Current';
else title = 'Click to load';
}
btn.title = title;
container.appendChild(btn);
}
// Phrase dropdown
const phraseSelect = document.createElement('select');
phraseSelect.style.cssText = `
width: 42px; height: 28px; padding: 0 2px; margin-left: 6px; margin-top: 5px;
background: #222; border: 1px solid #666; color: #FFF;
font-size: 9px; cursor: pointer; font-family: 'IBM Plex Mono', monospace;
vertical-align: top;
`;
[1, 2, 3, 4, 6, 8, 12, 16, 32, 64].forEach(val => {
const opt = document.createElement('option');
opt.value = val; opt.textContent = val;
if (val === seqChapterPhrase) opt.selected = true;
phraseSelect.appendChild(opt);
});
if (![1, 2, 3, 4, 6, 8, 12, 16, 32, 64].includes(seqChapterPhrase)) {
const opt = document.createElement('option');
opt.value = seqChapterPhrase; opt.textContent = seqChapterPhrase; opt.selected = true;
phraseSelect.appendChild(opt);
}
phraseSelect.onchange = (e) => setChapterPhrase(e.target.value);
phraseSelect.ondblclick = () => {
const input = document.createElement('input');
input.type = 'number'; input.min = 1; input.max = 999; input.value = seqChapterPhrase;
input.style.cssText = phraseSelect.style.cssText + 'text-align:center;';
input.onblur = () => { setChapterPhrase(input.value); renderChapterButtons(); };
input.onkeydown = (e) => {
if (e.key === 'Enter') { setChapterPhrase(input.value); renderChapterButtons(); }
else if (e.key === 'Escape') { renderChapterButtons(); }
};
phraseSelect.replaceWith(input); input.focus(); input.select();
};
phraseSelect.title = 'Bars per chapter (double-click for custom)';
container.appendChild(phraseSelect);
// CPY button
const cpyBtn = document.createElement('button');
cpyBtn.textContent = 'CPY';
cpyBtn.style.cssText = `
width: 32px; height: 28px; padding: 0; margin-left: 4px;
background: ${seqChapterCopyMode ? '#F0F' : '#222'};
border: 1px solid ${seqChapterCopyMode ? '#F0F' : '#666'};
color: ${seqChapterCopyMode ? '#000' : '#888'};
font-size: 8px; font-weight: bold; cursor: pointer; font-family: 'IBM Plex Mono', monospace;
vertical-align: top;
`;
cpyBtn.onclick = () => {
seqChapterCopyMode = !seqChapterCopyMode;
if (seqChapterCopyMode) {
seqChapterClearMode = false;
seqChapterBlendMode = false;
}
renderChapterButtons();
if (seqChapterCopyMode) logEvolution('SELECT TARGET...');
};
cpyBtn.title = 'Copy current chapter to another slot';
container.appendChild(cpyBtn);
// CLR button
const clrBtn = document.createElement('button');
clrBtn.textContent = 'CLR';
clrBtn.style.cssText = `
width: 32px; height: 28px; padding: 0; margin-left: 2px;
background: ${seqChapterClearMode ? '#F44' : '#222'};
border: 1px solid ${seqChapterClearMode ? '#F44' : '#666'};
color: ${seqChapterClearMode ? '#000' : '#888'};
font-size: 8px; font-weight: bold; cursor: pointer; font-family: 'IBM Plex Mono', monospace;
vertical-align: top;
${seqChapterClearMode ? 'animation: blink 0.4s infinite;' : ''}
`;
clrBtn.onclick = () => {
seqChapterClearMode = !seqChapterClearMode;
if (seqChapterClearMode) {
seqChapterCopyMode = false;
seqChapterBlendMode = false;
}
renderChapterButtons();
if (seqChapterClearMode) logEvolution('SELECT TO CLEAR...');
};
clrBtn.title = 'Clear a chapter slot';
container.appendChild(clrBtn);
// BLEND button
const blendBtn = document.createElement('button');
blendBtn.textContent = 'BLND';
blendBtn.style.cssText = `
width: 36px; height: 28px; padding: 0; margin-left: 2px;
background: ${seqChapterBlendMode ? '#FA0' : '#222'};
border: 1px solid ${seqChapterBlendMode ? '#FA0' : '#666'};
color: ${seqChapterBlendMode ? '#000' : '#888'};
font-size: 8px; font-weight: bold; cursor: pointer; font-family: 'IBM Plex Mono', monospace;
vertical-align: top;
${seqChapterBlendMode ? 'animation: chapterPulse 0.6s ease-in-out infinite;' : ''}
`;
blendBtn.onclick = () => {
seqChapterBlendMode = !seqChapterBlendMode;
if (seqChapterBlendMode) {
seqChapterCopyMode = false;
seqChapterClearMode = false;
logEvolution('SELECT BLEND TARGET...');
}
renderChapterButtons();
};
blendBtn.title = 'Blend current chapter into another slot';
container.appendChild(blendBtn);
// Bar counter when playing
if (seqChapterPlaying) {
const counter = document.createElement('span');
counter.style.cssText = 'margin-left: 6px; color: #0F0; font-size: 9px; font-family: IBM Plex Mono, monospace;';
counter.textContent = (seqChapterBarCount + 1) + '/' + seqChapterPhrase;
container.appendChild(counter);
}
}
let seqDrift = { kick: 'none', snare: 'none', hihat: 'none', bass: 'none', pad: 'none' };
let seqDriftSpeed = { kick: 4, snare: 4, hihat: 4, bass: 4, pad: 4 }; // 1=slow, 8=fast
let seqDriftCounters = { kick: 0, snare: 0, hihat: 0, bass: 0, pad: 0 };
let seqVolume = { kick: 100, snare: 100, hihat: 100, bass: 100, pad: 100 }; // 0-100
let seqMute = { kick: false, snare: false, hihat: false, bass: false, pad: false };
let seqSolo = { kick: false, snare: false, hihat: false, bass: false, pad: false };
let seqCanvas, seqCtx;
let seqGardenCanvas, seqGardenCtx;
let seqGardenFlowers = []; // [{x, snapshot, type:'flower'|'bush', color, barNum}]
let seqDancers = {
magenta: { x: 280, y: 24, dir: 1, frame: 0 },
cyan: { x: 360, y: 24, dir: -1, frame: 0 }
};
let seqLastBarForFlower = -1;
const SEQ_MAX_FLOWERS = 48;
let seqAnimFrame = null;
let seqIsDrawing = false;
let seqPaintedCells = new Set(); // Track cells painted in current drag
let seqPaintMode = null; // 'on' or 'off' - set by first click
let seqPlocks = {}; // Per-step parameter locks: { 'kick-0': { vel: 80, prob: 100, pitch: 0 }, ... }
let currentPlockVoice = null;
let currentPlockStep = null;
let longPressTimer = null;
let plockUndoState = null; // For undo
// HSL colors for each voice - expanded palette
const VOICE_HSL = {
kick: { h: 320, s: 100, lBase: 25 }, // Deep magenta
snare: { h: 330, s: 90, lBase: 40 }, // Bright magenta
hihat: { h: 340, s: 80, lBase: 55 }, // Light pink
bass: { h: 280, s: 70, lBase: 30 }, // Purple
pad: { h: 300, s: 60, lBase: 45 }, // Violet
clap: { h: 310, s: 85, lBase: 50 }, // Pink
perc: { h: 160, s: 70, lBase: 40 }, // Teal
// New drums
shaker: { h: 30, s: 80, lBase: 50 }, // Orange
cowbell: { h: 50, s: 90, lBase: 55 }, // Yellow
tom: { h: 0, s: 70, lBase: 45 }, // Red
conga: { h: 15, s: 75, lBase: 50 }, // Red-orange
// New melodic
lead: { h: 180, s: 80, lBase: 50 }, // Cyan
stab: { h: 120, s: 70, lBase: 45 }, // Green
whammy: { h: 240, s: 80, lBase: 50 }, // Blue
xpoly: { h: 270, s: 75, lBase: 55 }, // Purple-blue
// Special sub
sub: { h: 335, s: 100, lBase: 35 } // Hot pink/magenta
};
function openSequencer() {
document.getElementById('sequencerOverlay').style.display = 'flex';
populateKitDropdown();
// Sync ghost prob slider with current value (don't override user setting)
const ghostSlider = document.getElementById('seqGhostProb');
if (ghostSlider) {
ghostSlider.value = seqGhostProb;
}
// Initialize lead voice panel
updateLeadVoicePanel();
// Initialize voice panel selector (defaults to lead)
switchVoicePanel('lead');
// Initialize performance pad
initPerfPad();
// Sync transport state with current playback
if (breakbeat && breakbeat.isPlaying) {
seqTransportState = 'playing';
} else if (seqTransportState === 'playing') {
seqTransportState = 'paused';
}
updateTransportUI(); // This will also sync the REC button state
seqCanvas = document.getElementById('seqCanvas');
seqCtx = seqCanvas.getContext('2d');
// Initialize garden canvas
seqGardenCanvas = document.getElementById('seqGardenCanvas');
if (seqGardenCanvas) {
seqGardenCtx = seqGardenCanvas.getContext('2d');
seqGardenCanvas.onclick = handleGardenClick;
}
// Setup user interaction tracking to prevent automatic changes while editing
function setupUserInteractionTracking() {
// Track mouse/touch events on sequencer
const sequencerOverlay = document.getElementById('sequencerOverlay');
if (sequencerOverlay) {
['mousedown', 'mousemove', 'touchstart', 'touchmove'].forEach(eventType => {
sequencerOverlay.addEventListener(eventType, markUserEditing, { passive: true });
});
}
// Track input/change events on all controls (sliders, dropdowns, inputs)
const trackControl = (element) => {
if (!element) return;
['input', 'change', 'mousedown', 'touchstart'].forEach(eventType => {
element.addEventListener(eventType, markUserEditing, { passive: true });
});
};
// Track sequencer controls
const seqControls = [
'seqBpmInput', 'seqBpmLockBtn', 'seqKitSelect', 'seqKitPhraseSelect',
'seqMotifVoice', 'seqMotifBarsSelect', 'seqChaosSlider', 'seqRallySlider',
'seqEvolveRateSlider', 'seqGhostProbSlider', 'seqMasterGainSlider'
];
seqControls.forEach(id => {
const el = document.getElementById(id);
if (el) trackControl(el);
});
// Track deep settings modals
const deepModals = [
'leadVoiceSettingsModal', 'kickDeepSettingsModal', 'snareDeepSettingsModal',
'hihatDeepSettingsModal', 'bassDeepSettingsModal', 'padDeepSettingsModal'
];
deepModals.forEach(id => {
const modal = document.getElementById(id);
if (modal) {
['mousedown', 'mousemove', 'touchstart', 'touchmove'].forEach(eventType => {
modal.addEventListener(eventType, markUserEditing, { passive: true });
});
// Track all inputs/sliders inside modal
modal.querySelectorAll('input, select, button').forEach(el => {
trackControl(el);
});
}
});
// Track voice motif panels
const motifPanels = document.querySelectorAll('[id^="voiceMotifPanel"]');
motifPanels.forEach(panel => {
['mousedown', 'mousemove', 'touchstart', 'touchmove'].forEach(eventType => {
panel.addEventListener(eventType, markUserEditing, { passive: true });
});
panel.querySelectorAll('input, select, button').forEach(el => {
trackControl(el);
});
});
// Track mouse leave events - clear editing flag when mouse leaves sequencer area
if (sequencerOverlay) {
sequencerOverlay.addEventListener('mouseleave', () => {
// Small delay before clearing - in case user is just moving between controls
setTimeout(() => {
if (userEditTimeout) {
clearTimeout(userEditTimeout);
userEditTimeout = setTimeout(() => {
userIsEditing = false;
userEditTimeout = null;
}, USER_EDIT_IDLE_TIME);
}
}, 100);
});
}
}
// Point directly to live patterns - LIVE AF!
if (breakbeat && breakbeat.patterns) {
seqPatterns = breakbeat.patterns;
seqBpm = breakbeat.bpm || 90;
// CRITICAL: Decouple all voices immediately after assignment
ensureVoiceIndependence();
// Mark all existing pattern values as "user-painted" so they show colored
// Ghost notes will only be NEW algorithmic additions after this point
seqUserPainted = {};
// Mark by voiceId for each voice in voiceList (supports secondary voices)
voiceList.forEach(v => {
const pattern = seqPatterns[v.id] || seqPatterns[v.type];
if (pattern && Array.isArray(pattern)) {
for (let step = 0; step < pattern.length; step++) {
if (pattern[step] > 0) {
seqUserPainted[`${v.id}-${step}`] = true;
}
}
}
});
}
// Initialize state for all voices
voiceList.forEach((v, idx) => {
if (seqDrift[v.id] === undefined) seqDrift[v.id] = 'none';
if (seqDriftSpeed[v.id] === undefined) seqDriftSpeed[v.id] = 4;
if (seqDriftCounters[v.id] === undefined) seqDriftCounters[v.id] = 0;
if (seqVolume[v.id] === undefined) seqVolume[v.id] = 100;
if (seqMute[v.id] === undefined) seqMute[v.id] = false;
if (seqSolo[v.id] === undefined) seqSolo[v.id] = false;
});
// Render dynamic drift controls
renderDriftControls();
// Render chapter buttons and collections UI
renderChapterButtons();
renderChapterCollectionUI();
// User interaction tracking DISABLED - chapters always advance regardless of user interaction
// setupUserInteractionTracking();
// Setup canvas events
seqCanvas.onmousedown = (e) => {
markUserEditing(); // User is interacting with sequencer
if (e.button === 0) { // Left click
seqIsDrawing = true;
seqPaintedCells.clear();
seqPaintMode = null;
// Start long press timer for p-lock
longPressTimer = setTimeout(() => {
seqIsDrawing = false;
openPlockAtMouse(e);
}, 400);
paintAtMouse(e, true);
}
};
seqCanvas.onmousemove = (e) => {
if (seqIsDrawing) {
markUserEditing(); // User is actively drawing
clearTimeout(longPressTimer); // Cancel long press if moving
paintAtMouse(e, false);
}
};
seqCanvas.onmouseup = () => {
clearTimeout(longPressTimer);
seqIsDrawing = false;
seqPaintedCells.clear();
};
seqCanvas.onmouseleave = () => {
clearTimeout(longPressTimer);
seqIsDrawing = false;
seqPaintedCells.clear();
};
seqCanvas.oncontextmenu = (e) => {
e.preventDefault();
markUserEditing(); // User is interacting
openPlockAtMouse(e);
};
// Touch event tracking for mobile
seqCanvas.addEventListener('touchstart', (e) => {
markUserEditing();
if (e.touches.length === 1) {
seqIsDrawing = true;
seqPaintedCells.clear();
seqPaintMode = null;
const touch = e.touches[0];
const rect = seqCanvas.getBoundingClientRect();
const x = touch.clientX - rect.left;
const y = touch.clientY - rect.top;
paintAtMouse({ clientX: rect.left + x, clientY: rect.top + y }, true);
}
}, { passive: false });
seqCanvas.addEventListener('touchmove', (e) => {
if (seqIsDrawing && e.touches.length === 1) {
markUserEditing();
e.preventDefault();
const touch = e.touches[0];
const rect = seqCanvas.getBoundingClientRect();
const x = touch.clientX - rect.left;
const y = touch.clientY - rect.top;
paintAtMouse({ clientX: rect.left + x, clientY: rect.top + y }, false);
}
}, { passive: false });
seqCanvas.addEventListener('touchend', () => {
seqIsDrawing = false;
seqPaintedCells.clear();
}, { passive: true });
renderSnapshotList();
updateSeqInfo();
startSeqAnimation();
}
function closeSequencer() {
document.getElementById('sequencerOverlay').style.display = 'none';
if (seqAnimFrame) cancelAnimationFrame(seqAnimFrame);
seqAnimFrame = null;
// Keep music playing but silence wet FX that could cause issues during game
// (delay tails, reverb, chorus artifacts, perf pad effects)
silenceLeadFxBus();
if (typeof resetPerfPadEffects === 'function') resetPerfPadEffects();
// Clean up any stuck states
closePlockModal();
clearTimeout(longPressTimer);
seqIsDrawing = false;
// Cancel any active call/response
if (callResponseTimeout) {
clearTimeout(callResponseTimeout);
callResponseTimeout = null;
}
callResponseFadeTimers.forEach(t => clearTimeout(t));
callResponseFadeTimers = [];
callResponseSide = null;
}
// Transport state
let seqTransportState = 'stopped'; // 'playing', 'paused', 'stopped'
let seqRecording = false;
let seqPausedStep = 0; // Remember step when paused
let seqPausedBarCount = 0; // Remember bar when paused
function updateTransportUI() {
const playPauseBtn = document.getElementById('seqPlayPauseBtn');
const stopBtn = document.getElementById('seqStopBtn');
const recBtn = document.getElementById('seqRecBtn');
const status = document.getElementById('seqTransportStatus');
if (!playPauseBtn) return;
// Update button states
if (seqTransportState === 'playing') {
playPauseBtn.textContent = '⏸ PAUSE';
playPauseBtn.style.background = 'rgb(255 255 0 / 60%)';
playPauseBtn.style.borderColor = '#FF0';
} else {
playPauseBtn.textContent = '▶ PLAY';
playPauseBtn.style.background = 'rgb(0 255 0 / 60%)';
playPauseBtn.style.borderColor = '#0F0';
}
stopBtn.style.background = seqTransportState === 'stopped' ? 'rgb(170 34 34 / 60%)' : 'rgb(255 68 68 / 60%)';
// Sync REC button state
if (recBtn) {
if (isRecording) {
recBtn.textContent = '⏹ Stop';
recBtn.style.background = '#F00';
recBtn.style.borderColor = '#F00';
} else if (recordingPending) {
recBtn.textContent = '⏳ Wait';
recBtn.style.background = '#F80';
} else if (stopRecordingPending) {
recBtn.textContent = '⏳ End';
recBtn.style.background = '#F80';
} else {
recBtn.textContent = '⏺ REC';
recBtn.style.background = '#333';
recBtn.style.borderColor = '#F00';
}
}
// Update status text
let statusText = seqTransportState.toUpperCase();
if (breakbeat && breakbeat.bpm) statusText += ` | ${Math.round(breakbeat.bpm)} BPM`;
if (status) status.textContent = statusText;
}
// LIVE mode - auto-save patterns as you play
let seqLiveMode = false;
function toggleSeqLive() {
seqLiveMode = !seqLiveMode;
const btn = document.getElementById('seqLiveBtn');
if (btn) {
btn.style.background = seqLiveMode ? 'rgb(0 255 0 / 60%)' : 'rgb(255 170 0 / 60%)';
btn.style.borderColor = seqLiveMode ? '#0F0' : '#FA0';
btn.textContent = seqLiveMode ? 'LIVE ●' : 'LIVE';
}
logEvolution(seqLiveMode ? 'LIVE MODE ON' : 'LIVE MODE OFF');
}
function seqTogglePlayPause() {
if (seqTransportState === 'playing') {
// Pause
if (!breakbeat.isPlaying) return;
// Save position before stopping
seqPausedStep = breakbeat.currentStep || 0;
seqPausedBarCount = breakbeat.barCount || 0;
// Stop the scheduler but keep state
breakbeat.isPlaying = false;
if (breakbeat.timerID) {
clearTimeout(breakbeat.timerID);
breakbeat.timerID = null;
}
seqTransportState = 'paused';
} else {
// Play (from stopped or paused)
initAudio(); // Ensure audio context is ready
if (seqTransportState === 'paused') {
// Resume from pause - don't regenerate patterns
// Restore step position and restart scheduler
breakbeat.currentStep = seqPausedStep;
breakbeat.barCount = seqPausedBarCount;
breakbeat.isPlaying = true;
breakbeat.nextNoteTime = audioCtx.currentTime + 0.05;
scheduler();
} else {
// Start fresh if stopped
if (!breakbeat.isPlaying) {
startBreakbeat();
}
}
seqTransportState = 'playing';
}
updateTransportUI();
updatePlayStopButton(); // Sync main modal button
}
function seqToggleRecording() {
if (!window.MediaRecorder) {
logEvolution('REC: MediaRecorder not supported');
return;
}
// Use the same quantized recording system
if (!isRecording && !recordingPending) {
// Queue recording to start on next bar boundary
recordingPending = true;
// If not playing, start playback (sync with play)
if (!breakbeat || !breakbeat.isPlaying) {
if (seqTransportState === 'stopped') {
seqTogglePlayPause(); // Start playing
}
}
// Update UI
const seqBtn = document.getElementById('seqRecBtn');
if (seqBtn) {
seqBtn.textContent = '⏳ Wait';
seqBtn.style.background = '#F80';
seqBtn.classList.add('pulsing');
}
updateRecButtons('⏳ Wait');
logEvolution('REC: Queued to start on next bar');
} else if (isRecording && !stopRecordingPending) {
// Queue recording to stop on next bar boundary
stopRecordingPending = true;
// Update UI
const seqBtn = document.getElementById('seqRecBtn');
if (seqBtn) {
seqBtn.textContent = '⏳ End';
seqBtn.style.background = '#F80';
}
updateRecButtons('⏳ End');
logEvolution('REC: Queued to stop on next bar');
}
}
function seqStop() {
// Full stop - clears position
stopBreakbeat();
breakbeat.currentStep = 0;
seqPausedStep = 0;
seqPausedBarCount = 0;
// Silence FX bus to prevent residual noise
silenceLeadFxBus();
seqTransportState = 'stopped';
updateTransportUI();
updatePlayStopButton(); // Sync main modal button
}
function seqToggleRec() {
seqRecording = !seqRecording;
updateTransportUI();
// Visual feedback
const recBtn = document.getElementById('seqRecBtn');
if (recBtn) {
recBtn.textContent = seqRecording ? '● REC ON' : '● REC';
}
}
// Visibility change handler - refresh state when tab becomes visible
document.addEventListener('visibilitychange', () => {
if (!document.hidden && seqGardenCanvas) {
// Recalc flower ages based on elapsed bars (in case audio kept playing)
if (breakbeat && typeof seqLastBarForFlower !== 'undefined') {
const missedBars = (breakbeat.barCount || 0) - seqLastBarForFlower;
if (missedBars > 0) {
// Age all flowers by missed bars
seqGardenFlowers.forEach(f => {
f.age = (f.age || 0) + missedBars;
});
// Remove over-aged flowers
seqGardenFlowers = seqGardenFlowers.filter(f => f.age <= 32);
seqLastBarForFlower = breakbeat.barCount;
}
}
updateSeqInfo();
// Force immediate render
renderSeqCanvas();
renderSeqGarden();
}
});
// === SEQUENCER GARDEN - Dancing characters plant flowers (matching game graphics) ===
function renderSeqGarden() {
if (!seqGardenCtx) return;
// ALWAYS use fixed internal canvas dimensions (CSS handles display scaling)
const W = 640;
const H = 48;
// Ensure canvas buffer stays at correct size (can get corrupted on resize)
if (seqGardenCanvas.width !== 640) seqGardenCanvas.width = 640;
if (seqGardenCanvas.height !== 48) seqGardenCanvas.height = 48;
const t = Date.now();
const ctx = seqGardenCtx;
// Clear with dark blue (matching game background)
ctx.fillStyle = '#000820';
ctx.fillRect(0, 0, W, H);
// Ground line
ctx.fillStyle = '#001040';
ctx.fillRect(0, H - 4, W, 4);
// Bushes on left and right sides - EXACT game graphics from drawObstacle
const drawGameBush = (bx, by, size, alpha) => {
ctx.globalAlpha = alpha;
ctx.fillStyle = '#0F0'; // colors.green from game
for (let i = 0; i < size; i += 4) {
for (let j = 0; j < size; j += 4) {
if ((i + j) % 8 === 0) {
ctx.fillRect(bx + i, by + j, 3, 3);
}
}
}
ctx.globalAlpha = 1;
};
// Left bushes - LARGER near center, SMALLER at edges
// All positioned so bottom doesn't get cut off (y + size < H - 4)
drawGameBush(8, H - 38, 10, 0.25); // Far left - tiny, way back
drawGameBush(40, H - 32, 14, 0.4); // Middle-far left
drawGameBush(75, H - 28, 18, 0.55); // Middle left
drawGameBush(115, H - 24, 20, 0.7); // Near center - largest
// Right bushes - mirror
drawGameBush(W - 18, H - 38, 10, 0.25); // Far right - tiny
drawGameBush(W - 54, H - 32, 14, 0.4); // Middle-far right
drawGameBush(W - 93, H - 28, 18, 0.55); // Middle right
drawGameBush(W - 135, H - 24, 20, 0.7); // Near center - largest
// Draw flowers as a path - older flowers slide outward and into distance
// Sort: furthest back (oldest) first, then newest in front
const sortedFlowers = [...seqGardenFlowers].sort((a, b) => (b.age || 0) - (a.age || 0));
sortedFlowers.forEach((f, idx) => {
const age = f.age || 0;
if (f.type === 'bush') return; // Skip bushes for now
// PATH EFFECT: flowers start beside dancers, slide outward as they age
// Age 0 = right beside dancers (x ~ 0.35 or 0.65)
// Age increases = slide toward edges AND up into distance
const side = f.side || (f.xRatio < 0.5 ? -1 : 1); // Left (-1) or right (+1)
const slot = f.slot || 0; // Position in the path (0 = closest to dancers)
// Calculate position based on age - older flowers slide outward
const ageProgress = Math.min(age / 32, 1); // Full journey over 32 bars
// X position: start near center, slide to edges (but not into bushes)
// ~26px closer to center = 0.04 ratio on 640px canvas
const startX = side < 0 ? 0.42 : 0.58; // Closer to dancers
const endX = side < 0 ? 0.18 : 0.82; // Stop before bushes
const xRatio = startX + (endX - startX) * ageProgress;
const x = xRatio * W;
// Y position: perspective - older = higher (further away)
const baseY = H - 16;
const yOffset = ageProgress * 22; // Rise into distance
const y = baseY - yOffset;
// Size: perspective - older = smaller (further away)
const size = 10 - ageProgress * 6;
// Alpha: fade as they go into distance
const alpha = 1 - ageProgress * 0.85;
if (alpha < 0.08 || size < 2) return;
ctx.globalAlpha = alpha;
const s = size;
const p = Math.max(2, Math.floor(s / 3));
// Stem - shorter as it gets further
ctx.fillStyle = '#0A0';
const stemH = Math.max(2, 6 - ageProgress * 4);
ctx.fillRect(x - 1, y, 2, stemH);
// Petals - 4 petals in cross pattern
ctx.fillStyle = f.color === '#FF0' ? '#FF0' : '#F00';
ctx.fillRect(x - p, y - s / 2, p, p);
ctx.fillRect(x, y - s / 2, p, p);
ctx.fillRect(x - s / 2, y - p, p, p);
ctx.fillRect(x + s / 6, y - p, p, p);
// Center dot
ctx.fillStyle = f.color === '#FF0' ? '#F00' : '#FF0';
ctx.fillRect(x - p / 2, y - p, p, p);
ctx.globalAlpha = 1;
});
// Update dancer positions - stay close together in center
const centerX = W / 2;
const m = seqDancers.magenta;
const c = seqDancers.cyan;
// Dance moves cycle through different animations
const dancePhase = Math.floor(t / 800) % 6;
let mOffsetX = 0, mOffsetY = 0, cOffsetX = 0, cOffsetY = 0;
let mTilt = 0, cTilt = 0;
switch (dancePhase) {
case 0: // Bob together
mOffsetY = Math.sin(t / 150) * 3;
cOffsetY = Math.sin(t / 150) * 3;
break;
case 1: // Alternate bob
mOffsetY = Math.sin(t / 150) * 3;
cOffsetY = Math.sin(t / 150 + Math.PI) * 3;
break;
case 2: // Side lean toward each other
mOffsetX = Math.sin(t / 200) * 4;
cOffsetX = -Math.sin(t / 200) * 4;
mTilt = Math.sin(t / 200) * 0.1;
cTilt = -Math.sin(t / 200) * 0.1;
break;
case 3: // Jump!
const jumpPhase = (t % 400) / 400;
const jumpHeight = Math.sin(jumpPhase * Math.PI) * 8;
mOffsetY = -jumpHeight;
cOffsetY = -jumpHeight;
break;
case 4: // Wiggle
mOffsetX = Math.sin(t / 80) * 2;
cOffsetX = Math.sin(t / 80 + Math.PI) * 2;
mOffsetY = Math.abs(Math.sin(t / 80)) * 2;
cOffsetY = Math.abs(Math.sin(t / 80 + Math.PI)) * 2;
break;
case 5: // Spin closer
const spinT = (t % 800) / 800;
mOffsetX = Math.cos(spinT * Math.PI * 2) * 6 - 6;
cOffsetX = -Math.cos(spinT * Math.PI * 2) * 6 + 6;
break;
}
m.x = centerX - 14 + mOffsetX;
c.x = centerX + 14 + cOffsetX;
m.y = H - 20 + mOffsetY;
c.y = H - 20 + cOffsetY;
// Draw love line between them (dashed pink line like game)
const pulse = 0.5 + Math.sin(t / 300) * 0.3;
ctx.strokeStyle = `rgba(255, 100, 200, ${pulse})`;
ctx.lineWidth = 2;
ctx.setLineDash([4, 4]);
ctx.beginPath();
ctx.moveTo(m.x + 8, m.y);
ctx.lineTo(c.x - 8, c.y);
ctx.stroke();
ctx.setLineDash([]);
// Draw Magenta character with dance moves
const size = 14;
const mx = m.x, my = m.y;
ctx.save();
ctx.translate(mx, my);
ctx.rotate(mTilt);
ctx.fillStyle = '#F0F';
ctx.fillRect(-size / 2, -size / 2, size, size);
ctx.fillStyle = '#FFF';
ctx.fillRect(-size / 4, -size / 4, 3, 3);
ctx.fillRect(size / 4 - 3, -size / 4, 3, 3);
// Bow (red)
ctx.fillStyle = '#F00';
ctx.fillRect(-size / 2 - 3, -size / 2 - 4, 8, 4);
ctx.restore();
// Draw Cyan character with dance moves
const cx = c.x, cy = c.y;
ctx.save();
ctx.translate(cx, cy);
ctx.rotate(cTilt);
ctx.fillStyle = '#0FF';
ctx.fillRect(-size / 2, -size / 2, size, size);
ctx.fillStyle = '#FFF';
ctx.fillRect(-size / 4, -size / 4, 3, 3);
ctx.fillRect(size / 4 - 3, -size / 4, 3, 3);
// Newsboy cap (yellow)
ctx.fillStyle = '#FF0';
ctx.fillRect(-size / 2 + 3, -size / 2 - 5, size - 6, 3);
ctx.fillRect(-size / 2 + 1, -size / 2 - 4, size - 2, 3);
ctx.fillRect(-size / 2, -size / 2 - 2, size, 2);
ctx.fillRect(-size / 2 - 3, -size / 2, 6, 2);
ctx.restore();
}
// Call/response state
let callResponseTimeout = null;
let callResponseFadeTimers = [];
let callResponseSide = null; // 'magenta' or 'cyan'
let callResponseBackup = {}; // Store wiped patterns to restore
let callResponseClickCount = 0; // Track repeated clicks on same side
let callResponseLastSide = null; // Which side was last clicked
function triggerCallResponse(side) {
// Duration based on bar setting (minimum 1 bar)
const barMs = (60 / seqBpm) * 4 * 1000;
const barSetting = parseInt(document.getElementById('seqMotifBars')?.value) || 4;
const duration = barMs * Math.max(1, barSetting / 2); // Half the bar setting, min 1 bar
// Clear any existing timeouts
if (callResponseTimeout) clearTimeout(callResponseTimeout);
callResponseFadeTimers.forEach(t => clearTimeout(t));
callResponseFadeTimers = [];
// Track repeated clicks on same side
if (side === callResponseLastSide && callResponseSide === side) {
callResponseClickCount++;
} else {
callResponseClickCount = 1;
// Restore backups when switching sides
if (Object.keys(callResponseBackup).length > 0) {
Object.keys(callResponseBackup).forEach(key => {
const voiceId = key.startsWith('_hero_') ? key.slice(6) : key;
if (seqPatterns && callResponseBackup[key]) {
seqPatterns[voiceId] = callResponseBackup[key];
}
if (breakbeat && breakbeat.voiceMute && !key.startsWith('_hero_')) {
breakbeat.voiceMute[voiceId] = false;
}
});
callResponseBackup = {};
}
}
callResponseLastSide = side;
callResponseSide = side;
if (!breakbeat) return;
// Hero voices only - hats always on!
// Cyan = kick + snare (rhythm heroes)
// Magenta = bass + pad (melodic heroes)
const heroVoices = side === 'magenta'
? ['bass', 'pad', 'hihat']
: ['kick', 'snare', 'hihat'];
// Mute non-hero voices AND wipe their patterns
// Ghosts still play from breakbeat.patterns (not muted at type level)
// Only reset backup on first click of a session (preserve originals for repeated clicks)
if (callResponseClickCount === 1) {
callResponseBackup = {};
}
const wipedVoices = [];
// First mute all non-hero voice types in scheduler
if (!breakbeat.voiceMute) breakbeat.voiceMute = {};
const allVoiceTypes = ['kick', 'snare', 'hihat', 'clap', 'bass', 'pad', 'tom', 'cowbell', 'conga'];
allVoiceTypes.forEach(v => {
breakbeat.voiceMute[v] = !heroVoices.includes(v);
});
// Wipe seqPatterns for visual feedback (that's what renderSeqCanvas reads)
voiceList.forEach(voice => {
const baseType = voice.type;
// Mute this specific voice in scheduler
breakbeat.voiceMute[voice.id] = !heroVoices.includes(baseType);
if (!heroVoices.includes(baseType)) {
// Only backup on first click (don't overwrite with already-wiped patterns)
if (callResponseClickCount === 1 && seqPatterns[voice.id]) {
callResponseBackup[voice.id] = [...seqPatterns[voice.id]];
}
// Always wipe for visual
if (seqPatterns[voice.id]) {
seqPatterns[voice.id] = seqPatterns[voice.id].map(() => 0);
}
wipedVoices.push(voice);
}
});
logEvolution(side === 'magenta' ? 'MELODICS SOLO' : 'DRUMS SOLO');
// Make the solo INTERESTING - evolves with repeated clicks!
// Click 1-2: Build up (add notes)
// Click 3-4: Peak intensity (more notes, syncopation)
// Click 5+: Prune back (remove notes, create space)
const stepMs = barMs / 16;
const clickPhase = callResponseClickCount <= 2 ? 'build'
: callResponseClickCount <= 4 ? 'peak'
: 'prune';
setTimeout(() => {
if (callResponseSide !== side) return; // Aborted
heroVoices.forEach(voiceType => {
if (voiceType === 'hihat') return; // Hihat keeps its groove
const voice = voiceList.find(v => v.type === voiceType);
if (!voice || !seqPatterns[voice.id]) return;
// Only backup on first click of a solo session
if (callResponseClickCount === 1 && !callResponseBackup['_hero_' + voice.id]) {
callResponseBackup['_hero_' + voice.id] = [...seqPatterns[voice.id]];
}
const pattern = seqPatterns[voice.id];
if (clickPhase === 'build') {
// Add a few fills - building energy
const fillSteps = voiceType === 'kick' || voiceType === 'snare'
? [2, 6, 10, 14] : [3, 7, 11, 15];
fillSteps.forEach(step => {
if (pattern[step] === 0 && Math.random() > 0.5) {
pattern[step] = 0.5 + Math.random() * 0.3;
seqUserPainted[`${voice.id}-${step}`] = true;
}
});
logEvolution('Building...');
} else if (clickPhase === 'peak') {
// Maximum intensity - lots of notes, offbeats
const fillSteps = voiceType === 'kick' || voiceType === 'snare'
? [1, 2, 5, 6, 9, 10, 13, 14] : [1, 2, 3, 5, 6, 7, 9, 10, 11, 13, 14, 15];
fillSteps.forEach(step => {
if (pattern[step] === 0 && Math.random() > 0.3) {
pattern[step] = 0.7 + Math.random() * 0.3;
seqUserPainted[`${voice.id}-${step}`] = true;
}
});
logEvolution('PEAK!');
} else {
// Prune - create space, remove some notes
for (let i = 0; i < 16; i++) {
if (pattern[i] > 0 && Math.random() > 0.6) {
pattern[i] = 0;
delete seqUserPainted[`${voice.id}-${i}`];
}
}
// Maybe add one accent hit
const accentStep = [0, 4, 8, 12][Math.floor(Math.random() * 4)];
if (Math.random() > 0.5) {
pattern[accentStep] = 1.0;
seqUserPainted[`${voice.id}-${accentStep}`] = true;
}
logEvolution('Space...');
}
});
renderSeqCanvas();
}, stepMs * (clickPhase === 'prune' ? 0 : 2)); // Prune is instant, others have anticipation
renderSeqCanvas(); // Show wiped patterns
// Cadence logic: restore voice patterns step by step AND unmute
const fadeInDelay = duration / (wipedVoices.length + 1);
wipedVoices.forEach((voice, i) => {
const timer = setTimeout(() => {
// Restore this voice's seqPatterns from backup
if (callResponseBackup[voice.id] && seqPatterns) {
seqPatterns[voice.id] = callResponseBackup[voice.id];
}
// Unmute this voice
if (breakbeat && breakbeat.voiceMute) {
breakbeat.voiceMute[voice.id] = false;
breakbeat.voiceMute[voice.type] = false;
}
renderSeqCanvas();
}, duration + (fadeInDelay * (i + 1)));
callResponseFadeTimers.push(timer);
});
// Final restore after all fades complete
callResponseTimeout = setTimeout(() => {
callResponseSide = null;
// Restore hero patterns from backup
Object.keys(callResponseBackup).forEach(key => {
if (key.startsWith('_hero_')) {
const voiceId = key.slice(6);
if (seqPatterns && callResponseBackup[key]) {
seqPatterns[voiceId] = callResponseBackup[key];
}
}
});
callResponseBackup = {};
updateMuteSoloState(); // Ensure final state matches UI
renderSeqCanvas();
}, duration + (fadeInDelay * (wipedVoices.length + 2)));
}
// Octave shift from bush clicks
let currentOctaveShift = 0;
let lastBushClickTime = 0;
function shiftOctave(amount) {
currentOctaveShift = Math.max(-4, Math.min(4, currentOctaveShift + amount));
if (breakbeat) {
breakbeat.octaveShift = currentOctaveShift;
}
logEvolution(`OCT: ${currentOctaveShift > 0 ? '+' : ''}${currentOctaveShift}`);
}
function resetOctave() {
currentOctaveShift = 0;
if (breakbeat) {
breakbeat.octaveShift = 0;
}
logEvolution(`OCT: 0 (reset)`);
}
function handleGardenClick(e) {
if (!seqGardenCanvas) return;
const rect = seqGardenCanvas.getBoundingClientRect();
const W = 640;
const H = 48;
// Account for centered canvas positioning
const canvasDisplayWidth = rect.height * (W / H);
const canvasOffsetX = (rect.width - canvasDisplayWidth) / 2;
const clickX = ((e.clientX - rect.left - canvasOffsetX) / canvasDisplayWidth) * W;
const clickY = ((e.clientY - rect.top) / rect.height) * H;
const centerX = W / 2;
// Check dancer clicks first - call/response!
// Magenta is at centerX - 14, Cyan at centerX + 14
const dancerY = H - 20;
if (clickY > dancerY - 15 && clickY < dancerY + 15) {
if (Math.abs(clickX - (centerX - 14)) < 15) {
triggerCallResponse('magenta');
return;
}
if (Math.abs(clickX - (centerX + 14)) < 15) {
triggerCallResponse('cyan');
return;
}
}
// Check bush clicks - octave shift
// Left bushes: 8, 40, 75, 115 → octaves -4, -3, -2, -1
// Right bushes: W-135, W-93, W-54, W-18 → octaves +1, +2, +3, +4
const leftBushes = [{ x: 8, oct: -4 }, { x: 40, oct: -3 }, { x: 75, oct: -2 }, { x: 115, oct: -1 }];
const rightBushes = [{ x: W - 135, oct: 1 }, { x: W - 93, oct: 2 }, { x: W - 54, oct: 3 }, { x: W - 18, oct: 4 }];
const allBushes = [...leftBushes, ...rightBushes];
for (const bush of allBushes) {
if (Math.abs(clickX - bush.x) < 20 && clickY > H - 45) {
const now = Date.now();
// Double-click on any bush = reset to 0
if (now - lastBushClickTime < 350) {
resetOctave();
} else {
shiftOctave(bush.oct - currentOctaveShift); // Set to this octave
}
lastBushClickTime = now;
return;
}
}
// Check if clicked on a flower - use same position calc as rendering
for (let i = seqGardenFlowers.length - 1; i >= 0; i--) {
const f = seqGardenFlowers[i];
if (f.type === 'bush' || !f.snapshot) continue;
const age = f.age || 0;
const side = f.side || (f.xRatio < 0.5 ? -1 : 1);
const ageProgress = Math.min(age / 32, 1);
// Same position calculation as rendering
const startX = side < 0 ? 0.42 : 0.58;
const endX = side < 0 ? 0.18 : 0.82;
const xRatio = startX + (endX - startX) * ageProgress;
const flowerX = xRatio * W;
const baseY = H - 16;
const yOffset = ageProgress * 22;
const flowerY = baseY - yOffset;
// Hit detection - bigger hitbox for easier clicking
if (Math.abs(clickX - flowerX) < 15 && Math.abs(clickY - flowerY) < 15) {
loadSeqSnapshot(f.snapshot);
logEvolution(`REWIND bar ${f.barNum || i}`);
return;
}
}
}
function plantFlower(type, snapshot) {
// Age existing flowers - they drift up and fade
seqGardenFlowers.forEach(f => {
f.age = (f.age || 0) + 1;
});
// Convert very old flowers to snapshot buttons (move to rewind area)
const oldFlowers = seqGardenFlowers.filter(f => f.age > 12 && f.snapshot);
oldFlowers.forEach(f => {
if (!f.pinned) {
f.pinned = true;
addSnapshotButton(f);
}
});
// Remove ancient flowers from garden (max 32 bars - they've reached the edge)
seqGardenFlowers = seqGardenFlowers.filter(f => f.age <= 32);
// PATH SYSTEM: new flowers spawn beside dancers, alternating left/right
// Use a persistent toggle instead of counting (which changes as flowers age out)
if (typeof seqLastFlowerSide === 'undefined') seqLastFlowerSide = 1;
seqLastFlowerSide = seqLastFlowerSide * -1; // Toggle: -1 → 1 → -1 → 1
const side = seqLastFlowerSide;
const colors = ['#F00', '#FF0']; // Red or golden (like game)
seqGardenFlowers.push({
side: side, // -1 = left path, +1 = right path
xRatio: side < 0 ? 0.42 : 0.58, // Starting position closer to dancers
type,
snapshot: snapshot ? JSON.parse(JSON.stringify(snapshot)) : null,
color: colors[Math.floor(Math.random() * colors.length)],
barNum: breakbeat ? breakbeat.barCount : 0,
age: 0
});
}
function addSnapshotButton(flower) {
// Garden flowers just age out - don't add buttons to snapshotList
// The patternSnapshots system handles that separately
// This keeps garden visual only, snapshots stay clean with bar numbers
}
function loadSeqSnapshot(snapshot) {
if (!snapshot || !seqPatterns) return;
// Clear and rebuild user-painted tracking for restored patterns
seqUserPainted = {};
for (const key of Object.keys(snapshot)) {
if (seqPatterns[key] && Array.isArray(snapshot[key])) {
for (let i = 0; i < 16; i++) {
const val = snapshot[key][i] || 0;
seqPatterns[key][i] = val;
// Mark restored values as user-painted so they show colored
if (val > 0) {
seqUserPainted[`${key}-${i}`] = true;
}
}
}
}
}
// Drift is now applied on step boundaries in scheduler(), not time-based
let lastSeqInfoUpdate = 0;
function drawScope() {
const canvas = document.getElementById('scopeCanvas');
if (!canvas || !analyser) return;
const ctx = canvas.getContext('2d');
const w = canvas.width;
const h = canvas.height;
const bufferLength = analyser.frequencyBinCount;
const dataArray = new Uint8Array(bufferLength);
analyser.getByteTimeDomainData(dataArray);
ctx.fillStyle = '#0a0a0a';
ctx.fillRect(0, 0, w, h);
// Draw with gradient based on amplitude - green (quiet) to pink (loud)
const sliceWidth = w / bufferLength;
let x = 0;
ctx.lineWidth = 2;
ctx.beginPath();
for (let i = 0; i < bufferLength; i++) {
const v = dataArray[i] / 128.0;
const y = v * h / 2;
// Color based on distance from center (amplitude)
const amp = Math.abs(v - 1.0);
// Interpolate green (#0F0) to pink (#F0F) based on amplitude
const hue = 120 - amp * 180; // 120 (green) -> -60 (pink/magenta)
ctx.strokeStyle = `hsl(${hue < 0 ? hue + 360 : hue}, 100%, 50%)`;
if (i === 0) {
ctx.moveTo(x, y);
} else {
ctx.lineTo(x, y);
ctx.stroke();
ctx.beginPath();
ctx.moveTo(x, y);
}
x += sliceWidth;
}
}
// Performance Pad state
let perfPadActive = false;
let perfPadFilterCutoff = 1.0; // 0-1, 1 = fully open
let perfPadResonance = 0.5; // 0-1
let perfPadCurrentScene = -1; // -1 = no override
let perfPadLastX = 0.5;
let perfPadLastY = 0.5;
let perfPadVelocity = 0; // Movement speed 0-1
// Performance Pad settings (configurable via modal)
const perfPadSettings = {
filterMin: 80, // Hz
filterMax: 20000, // Hz
resMin: 1, // Q
resMax: 13, // Q
delayTime: 200, // ms
velocitySens: 0.5, // 0-1
masterGain: 1.0, // 0-2 (100% = 1.0)
morphMode: 'ghost', // 'ghost' or 'real'
enableDelay: true,
enableSaturation: true,
enableCrush: true
};
function openPerfPadSettings() {
const modal = document.getElementById('perfPadSettingsModal');
if (!modal) return;
// Populate current values
document.getElementById('ppFilterMin').value = perfPadSettings.filterMin;
document.getElementById('ppFilterMax').value = perfPadSettings.filterMax;
document.getElementById('ppResMin').value = perfPadSettings.resMin;
document.getElementById('ppResMax').value = perfPadSettings.resMax;
document.getElementById('ppDelayTime').value = perfPadSettings.delayTime;
document.getElementById('ppVelocitySens').value = perfPadSettings.velocitySens * 100;
document.getElementById('ppVelSensVal').textContent = Math.round(perfPadSettings.velocitySens * 100) + '%';
document.getElementById('ppMasterGain').value = perfPadSettings.masterGain * 100;
document.getElementById('ppGainVal').textContent = Math.round(perfPadSettings.masterGain * 100) + '%';
// Morph mode
document.querySelector(`input[name="ppMorphMode"][value="${perfPadSettings.morphMode}"]`).checked = true;
// Effect toggles
document.getElementById('ppEnableDelay').checked = perfPadSettings.enableDelay;
document.getElementById('ppEnableSat').checked = perfPadSettings.enableSaturation;
document.getElementById('ppEnableCrush').checked = perfPadSettings.enableCrush;
const backdrop = document.getElementById('modalBackdrop');
if (backdrop) backdrop.style.display = 'block';
modal.style.display = 'block';
}
function closePerfPadSettings() {
const modal = document.getElementById('perfPadSettingsModal');
const backdrop = document.getElementById('modalBackdrop');
if (modal) modal.style.display = 'none';
if (backdrop) backdrop.style.display = 'none';
}
function applyPerfPadGain() {
const slider = document.getElementById('ppMasterGain');
if (slider) {
setMasterGain(slider.value);
}
}
// Global master gain value (0-2, where 1.0 = 100%)
let globalMasterGain = 1.0;
function setMasterGain(value) {
globalMasterGain = parseInt(value) / 100;
perfPadSettings.masterGain = globalMasterGain;
if (musicGain && audioCtx) {
musicGain.gain.setTargetAtTime(globalMasterGain, audioCtx.currentTime, 0.05);
}
// Sync all gain sliders
const ppSlider = document.getElementById('ppMasterGain');
const seqSlider = document.getElementById('seqMasterGain');
const musicSlider = document.getElementById('musicMasterGain');
const randomSlider = document.getElementById('randomGain');
const ppVal = document.getElementById('ppGainVal');
const musicVal = document.getElementById('musicMasterGainVal');
const pct = Math.round(globalMasterGain * 100);
if (ppSlider) ppSlider.value = pct;
if (seqSlider) seqSlider.value = pct;
if (musicSlider) musicSlider.value = pct;
if (randomSlider) randomSlider.value = pct;
if (ppVal) ppVal.textContent = pct + '%';
if (musicVal) musicVal.textContent = pct + '%';
}
// === LEAD VOICE DEEP SETTINGS ===
const leadVoiceSettings = {
attack: 10, // ms
decay: 100, // ms
sustain: 70, // %
release: 200, // ms
filterCut: 4000, // Hz
filterRes: 2, // Q
filterType: 'lowpass',
vibRate: 5, // Hz
vibDepth: 20, // %
vibDelay: 50, // ms
volume: 100, // %
pan: 0, // -100 to 100
normalize: true,
octave: 0, // -3 to 3 (supports decimals for microtuning!)
detune: 0, // cents (-100 to 100)
drift: 0, // cents - random per-note microtuning (humanize)
glide: 0, // ms
lfoFilter: 0, // % - LFO modulation to filter
lfoRate: 4, // Hz - LFO rate for filter mod
stereoWidth: 0, // % - stereo spread
delaySend: 0, // %
reverbSend: 0, // %
drive: 0, // %
chorus: 0, // %
bitCrush: 0, // % - bit reduction
sampleRate: 0 // % - sample rate reduction
};
// Real-time DEEP settings update with preview
let deepPreviewTimer = null;
function updateDeepSetting(key, value, isFloat = false) {
leadVoiceSettings[key] = isFloat ? parseFloat(value) : parseInt(value);
// Debounced preview sound
clearTimeout(deepPreviewTimer);
deepPreviewTimer = setTimeout(() => previewDeepSound(), 100);
}
function previewDeepSound() {
initAudio();
const now = audioCtx.currentTime;
const note = 60 + (leadVoiceSettings.octave * 12);
const vol = (leadVoiceSettings.volume / 100) * 0.4;
// Play a short preview note with current deep settings
if (typeof playGBWave === 'function') {
playGBWave(now, note, vol, 0.4);
}
}
function openVoiceDeepSettings() {
// Route to the appropriate deep settings modal based on current voice
const voiceSelector = document.getElementById('voicePanelSelector');
const voiceType = voiceSelector ? voiceSelector.value : 'lead';
switch(voiceType) {
case 'lead':
openLeadVoiceSettings();
break;
case 'kick':
openKickDeepSettings();
break;
case 'snare':
openSnareDeepSettings();
break;
case 'hihat':
openHihatDeepSettings();
break;
case 'bass':
openBassDeepSettings();
break;
case 'pad':
openPadDeepSettings();
break;
default:
openLeadVoiceSettings();
}
}
function openLeadVoiceSettings() {
markUserEditing(); // User opened modal - they're about to edit
const modal = document.getElementById('leadVoiceSettingsModal');
const backdrop = document.getElementById('modalBackdrop');
if (!modal) return;
// Populate current values
document.getElementById('lvAttack').value = leadVoiceSettings.attack;
document.getElementById('lvAtkVal').textContent = leadVoiceSettings.attack + 'ms';
document.getElementById('lvDecay').value = leadVoiceSettings.decay;
document.getElementById('lvDecVal').textContent = leadVoiceSettings.decay + 'ms';
document.getElementById('lvSustain').value = leadVoiceSettings.sustain;
document.getElementById('lvSusVal').textContent = leadVoiceSettings.sustain + '%';
document.getElementById('lvRelease').value = leadVoiceSettings.release;
document.getElementById('lvRelVal').textContent = leadVoiceSettings.release + 'ms';
document.getElementById('lvFilterCut').value = leadVoiceSettings.filterCut;
document.getElementById('lvCutVal').textContent = (leadVoiceSettings.filterCut / 1000).toFixed(1) + 'k';
document.getElementById('lvFilterRes').value = leadVoiceSettings.filterRes;
document.getElementById('lvResVal').textContent = leadVoiceSettings.filterRes;
document.getElementById('lvFilterType').value = leadVoiceSettings.filterType;
document.getElementById('lvVibRate').value = leadVoiceSettings.vibRate;
document.getElementById('lvVibRateVal').textContent = leadVoiceSettings.vibRate + 'Hz';
document.getElementById('lvVibDepth').value = leadVoiceSettings.vibDepth;
document.getElementById('lvVibDepthVal').textContent = leadVoiceSettings.vibDepth + '%';
document.getElementById('lvVibDelay').value = leadVoiceSettings.vibDelay;
document.getElementById('lvVibDelayVal').textContent = leadVoiceSettings.vibDelay + 'ms';
document.getElementById('lvVolume').value = leadVoiceSettings.volume;
document.getElementById('lvVolVal').textContent = leadVoiceSettings.volume + '%';
document.getElementById('lvPan').value = leadVoiceSettings.pan;
document.getElementById('lvPanVal').textContent = leadVoiceSettings.pan === 0 ? 'C' : (leadVoiceSettings.pan > 0 ? 'R' : 'L') + Math.abs(leadVoiceSettings.pan);
document.getElementById('lvNormalize').checked = leadVoiceSettings.normalize;
document.getElementById('lvOctave').value = leadVoiceSettings.octave;
const octSign = leadVoiceSettings.octave >= 0 ? '+' : '';
document.getElementById('lvOctVal').textContent = octSign + parseFloat(leadVoiceSettings.octave).toFixed(2);
document.getElementById('lvDetune').value = leadVoiceSettings.detune;
document.getElementById('lvDetuneVal').textContent = leadVoiceSettings.detune + 'c';
document.getElementById('lvDrift').value = leadVoiceSettings.drift || 0;
document.getElementById('lvDriftVal').textContent = (leadVoiceSettings.drift || 0) + 'c';
document.getElementById('lvGlide').value = leadVoiceSettings.glide;
document.getElementById('lvGlideVal').textContent = leadVoiceSettings.glide + 'ms';
// Modulation
document.getElementById('lvLfoFilter').value = leadVoiceSettings.lfoFilter;
document.getElementById('lvLfoFilterVal').textContent = leadVoiceSettings.lfoFilter + '%';
document.getElementById('lvLfoRate').value = leadVoiceSettings.lfoRate;
document.getElementById('lvLfoRateVal').textContent = leadVoiceSettings.lfoRate + 'Hz';
document.getElementById('lvStereoWidth').value = leadVoiceSettings.stereoWidth;
document.getElementById('lvStereoVal').textContent = leadVoiceSettings.stereoWidth + '%';
// FX Sends
document.getElementById('lvDelaySend').value = leadVoiceSettings.delaySend;
document.getElementById('lvDelayVal').textContent = leadVoiceSettings.delaySend + '%';
document.getElementById('lvReverbSend').value = leadVoiceSettings.reverbSend;
document.getElementById('lvReverbVal').textContent = leadVoiceSettings.reverbSend + '%';
document.getElementById('lvDrive').value = leadVoiceSettings.drive;
document.getElementById('lvDriveVal').textContent = leadVoiceSettings.drive + '%';
document.getElementById('lvChorus').value = leadVoiceSettings.chorus;
document.getElementById('lvChorusVal').textContent = leadVoiceSettings.chorus + '%';
// Lo-Fi
document.getElementById('lvBitCrush').value = leadVoiceSettings.bitCrush;
document.getElementById('lvBitVal').textContent = leadVoiceSettings.bitCrush + '%';
document.getElementById('lvSampleRate').value = leadVoiceSettings.sampleRate;
document.getElementById('lvSrVal').textContent = leadVoiceSettings.sampleRate + '%';
// Apply To checkboxes (lead voices only)
const applyTo = leadVoiceSettings.applyTo || { xwave: true, sid: true, chip: true, fm: true, brass: true, poly: true };
document.getElementById('lvApplyXwave').checked = applyTo.xwave !== false;
document.getElementById('lvApplySid').checked = applyTo.sid !== false;
document.getElementById('lvApplyChip').checked = applyTo.chip !== false;
document.getElementById('lvApplyFm').checked = applyTo.fm !== false;
document.getElementById('lvApplyBrass').checked = applyTo.brass !== false;
document.getElementById('lvApplyPoly').checked = applyTo.poly !== false;
if (backdrop) backdrop.style.display = 'block';
modal.style.display = 'block';
}
function closeLeadVoiceSettings() {
const modal = document.getElementById('leadVoiceSettingsModal');
const backdrop = document.getElementById('modalBackdrop');
if (modal) modal.style.display = 'none';
if (backdrop) backdrop.style.display = 'none';
}
// Kick Deep Settings
function openKickDeepSettings() {
markUserEditing(); // User opened modal - they're about to edit
const modal = document.getElementById('kickDeepSettingsModal');
const backdrop = document.getElementById('modalBackdrop');
if (modal) modal.style.display = 'block';
if (backdrop) backdrop.style.display = 'block';
syncKickDeepUI();
}
function closeKickDeepSettings() {
const modal = document.getElementById('kickDeepSettingsModal');
const backdrop = document.getElementById('modalBackdrop');
if (modal) modal.style.display = 'none';
if (backdrop) backdrop.style.display = 'none';
}
// Snare Deep Settings
function openSnareDeepSettings() {
markUserEditing(); // User opened modal - they're about to edit
const modal = document.getElementById('snareDeepSettingsModal');
const backdrop = document.getElementById('modalBackdrop');
if (modal) modal.style.display = 'block';
if (backdrop) backdrop.style.display = 'block';
syncSnareDeepUI();
}
function closeSnareDeepSettings() {
const modal = document.getElementById('snareDeepSettingsModal');
const backdrop = document.getElementById('modalBackdrop');
if (modal) modal.style.display = 'none';
if (backdrop) backdrop.style.display = 'none';
}
// Hihat Deep Settings
function openHihatDeepSettings() {
markUserEditing(); // User opened modal - they're about to edit
const modal = document.getElementById('hihatDeepSettingsModal');
const backdrop = document.getElementById('modalBackdrop');
if (modal) modal.style.display = 'block';
if (backdrop) backdrop.style.display = 'block';
syncHihatDeepUI();
}
function closeHihatDeepSettings() {
const modal = document.getElementById('hihatDeepSettingsModal');
const backdrop = document.getElementById('modalBackdrop');
if (modal) modal.style.display = 'none';
if (backdrop) backdrop.style.display = 'none';
}
// Bass Deep Settings
function openBassDeepSettings() {
markUserEditing(); // User opened modal - they're about to edit
const modal = document.getElementById('bassDeepSettingsModal');
const backdrop = document.getElementById('modalBackdrop');
if (modal) modal.style.display = 'block';
if (backdrop) backdrop.style.display = 'block';
syncBassDeepUI();
}
function closeBassDeepSettings() {
const modal = document.getElementById('bassDeepSettingsModal');
const backdrop = document.getElementById('modalBackdrop');
if (modal) modal.style.display = 'none';
if (backdrop) backdrop.style.display = 'none';
}
// Pad Deep Settings
function openPadDeepSettings() {
markUserEditing(); // User opened modal - they're about to edit
const modal = document.getElementById('padDeepSettingsModal');
const backdrop = document.getElementById('modalBackdrop');
if (modal) modal.style.display = 'block';
if (backdrop) backdrop.style.display = 'block';
syncPadDeepUI();
}
function closePadDeepSettings() {
const modal = document.getElementById('padDeepSettingsModal');
const backdrop = document.getElementById('modalBackdrop');
if (modal) modal.style.display = 'none';
if (backdrop) backdrop.style.display = 'none';
}
// Reset functions for each voice
function resetKickDeepSettings() {
Object.assign(kickDeepSettings, kickDeepDefaults);
syncKickDeepUI();
logEvolution('KICK DEEP → RESET');
}
function resetSnareDeepSettings() {
Object.assign(snareDeepSettings, snareDeepDefaults);
syncSnareDeepUI();
logEvolution('SNARE DEEP → RESET');
}
function resetHihatDeepSettings() {
Object.assign(hihatDeepSettings, hihatDeepDefaults);
syncHihatDeepUI();
logEvolution('HIHAT DEEP → RESET');
}
function resetBassDeepSettings() {
Object.assign(bassDeepSettings, bassDeepDefaults);
syncBassDeepUI();
logEvolution('BASS DEEP → RESET');
}
function resetPadDeepSettings() {
Object.assign(padDeepSettings, padDeepDefaults);
syncPadDeepUI();
logEvolution('PAD DEEP → RESET');
}
// Sync UI functions (populate sliders from settings)
function syncKickDeepUI() {
document.getElementById('kickDeepPitch').value = kickDeepSettings.pitch;
document.getElementById('kickDeepPitchVal').textContent = kickDeepSettings.pitch + 'Hz';
document.getElementById('kickDeepDecay').value = kickDeepSettings.decay;
document.getElementById('kickDeepDecayVal').textContent = kickDeepSettings.decay + 'ms';
document.getElementById('kickDeepPunch').value = kickDeepSettings.punch;
document.getElementById('kickDeepPunchVal').textContent = kickDeepSettings.punch + '%';
document.getElementById('kickDeepDrive').value = kickDeepSettings.drive;
document.getElementById('kickDeepDriveVal').textContent = kickDeepSettings.drive + '%';
}
function syncSnareDeepUI() {
document.getElementById('snareDeepTone').value = snareDeepSettings.tone;
document.getElementById('snareDeepToneVal').textContent = snareDeepSettings.tone + 'Hz';
document.getElementById('snareDeepSnappy').value = snareDeepSettings.snappy;
document.getElementById('snareDeepSnappyVal').textContent = snareDeepSettings.snappy + '%';
document.getElementById('snareDeepDecay').value = snareDeepSettings.decay;
document.getElementById('snareDeepDecayVal').textContent = snareDeepSettings.decay + 'ms';
document.getElementById('snareDeepNoise').value = snareDeepSettings.noise;
document.getElementById('snareDeepNoiseVal').textContent = snareDeepSettings.noise + '%';
}
function syncHihatDeepUI() {
document.getElementById('hihatDeepClosed').value = hihatDeepSettings.closed;
document.getElementById('hihatDeepClosedVal').textContent = hihatDeepSettings.closed + 'ms';
document.getElementById('hihatDeepOpen').value = hihatDeepSettings.open;
document.getElementById('hihatDeepOpenVal').textContent = hihatDeepSettings.open + 'ms';
document.getElementById('hihatDeepTone').value = hihatDeepSettings.tone;
document.getElementById('hihatDeepToneVal').textContent = (hihatDeepSettings.tone/1000).toFixed(1) + 'kHz';
document.getElementById('hihatDeepRing').value = hihatDeepSettings.ring;
document.getElementById('hihatDeepRingVal').textContent = hihatDeepSettings.ring + '%';
}
function syncBassDeepUI() {
document.getElementById('bassDeepFilter').value = bassDeepSettings.filter;
document.getElementById('bassDeepFilterVal').textContent = (bassDeepSettings.filter/1000).toFixed(1) + 'kHz';
document.getElementById('bassDeepRes').value = bassDeepSettings.res;
document.getElementById('bassDeepResVal').textContent = bassDeepSettings.res;
document.getElementById('bassDeepSlide').value = bassDeepSettings.slide;
document.getElementById('bassDeepSlideVal').textContent = bassDeepSettings.slide + '%';
document.getElementById('bassDeepDecay').value = bassDeepSettings.decay;
document.getElementById('bassDeepDecayVal').textContent = bassDeepSettings.decay + 'ms';
}
function syncPadDeepUI() {
document.getElementById('padDeepAttack').value = padDeepSettings.attack;
document.getElementById('padDeepAttackVal').textContent = padDeepSettings.attack + 'ms';
document.getElementById('padDeepRelease').value = padDeepSettings.release;
document.getElementById('padDeepReleaseVal').textContent = padDeepSettings.release + 'ms';
document.getElementById('padDeepFilter').value = padDeepSettings.filter;
document.getElementById('padDeepFilterVal').textContent = (padDeepSettings.filter/1000).toFixed(1) + 'kHz';
document.getElementById('padDeepDetune').value = padDeepSettings.detune;
document.getElementById('padDeepDetuneVal').textContent = padDeepSettings.detune + '¢';
document.getElementById('padDeepVoices').value = padDeepSettings.voices;
document.getElementById('padDeepVoicesVal').textContent = padDeepSettings.voices;
}
function handleBackdropClick() {
// Deep settings modal requires intentional close - don't close on backdrop click
const deepModal = document.getElementById('leadVoiceSettingsModal');
if (deepModal && deepModal.style.display !== 'none') {
return; // Don't close deep settings on backdrop click
}
// Close perf pad settings if open
closePerfPadSettings();
}
function applyLeadVoiceSettings() {
// Read all values from modal
leadVoiceSettings.attack = parseInt(document.getElementById('lvAttack').value) || 10;
leadVoiceSettings.decay = parseInt(document.getElementById('lvDecay').value) || 100;
leadVoiceSettings.sustain = parseInt(document.getElementById('lvSustain').value) || 70;
leadVoiceSettings.release = parseInt(document.getElementById('lvRelease').value) || 200;
leadVoiceSettings.filterCut = parseInt(document.getElementById('lvFilterCut').value) || 4000;
leadVoiceSettings.filterRes = parseInt(document.getElementById('lvFilterRes').value) || 2;
leadVoiceSettings.filterType = document.getElementById('lvFilterType').value || 'lowpass';
leadVoiceSettings.vibRate = parseInt(document.getElementById('lvVibRate').value) || 5;
leadVoiceSettings.vibDepth = parseInt(document.getElementById('lvVibDepth').value) || 20;
leadVoiceSettings.vibDelay = parseInt(document.getElementById('lvVibDelay').value) || 50;
leadVoiceSettings.volume = parseInt(document.getElementById('lvVolume').value) || 100;
leadVoiceSettings.pan = parseInt(document.getElementById('lvPan').value) || 0;
leadVoiceSettings.normalize = document.getElementById('lvNormalize').checked;
leadVoiceSettings.octave = parseFloat(document.getElementById('lvOctave').value) || 0;
leadVoiceSettings.detune = parseInt(document.getElementById('lvDetune').value) || 0;
leadVoiceSettings.drift = parseFloat(document.getElementById('lvDrift').value) || 0;
leadVoiceSettings.glide = parseInt(document.getElementById('lvGlide').value) || 0;
// Modulation
leadVoiceSettings.lfoFilter = parseInt(document.getElementById('lvLfoFilter').value) || 0;
leadVoiceSettings.lfoRate = parseFloat(document.getElementById('lvLfoRate').value) || 4;
leadVoiceSettings.stereoWidth = parseInt(document.getElementById('lvStereoWidth').value) || 0;
// FX Sends
leadVoiceSettings.delaySend = parseInt(document.getElementById('lvDelaySend').value) || 0;
leadVoiceSettings.reverbSend = parseInt(document.getElementById('lvReverbSend').value) || 0;
leadVoiceSettings.drive = parseInt(document.getElementById('lvDrive').value) || 0;
leadVoiceSettings.chorus = parseInt(document.getElementById('lvChorus').value) || 0;
// Lo-Fi
leadVoiceSettings.bitCrush = parseInt(document.getElementById('lvBitCrush').value) || 0;
leadVoiceSettings.sampleRate = parseInt(document.getElementById('lvSampleRate').value) || 0;
// Get which lead voices to apply to
const applyTo = {
xwave: document.getElementById('lvApplyXwave').checked,
sid: document.getElementById('lvApplySid').checked,
chip: document.getElementById('lvApplyChip').checked,
fm: document.getElementById('lvApplyFm').checked,
brass: document.getElementById('lvApplyBrass').checked,
poly: document.getElementById('lvApplyPoly').checked
};
// Store which voices these settings apply to
leadVoiceSettings.applyTo = applyTo;
// Sync quick controls with updated values
syncLeadUniversalControls();
logEvolution('DEEP SETTINGS applied');
}
function resetLeadVoiceSettings() {
leadVoiceSettings.attack = 10;
leadVoiceSettings.decay = 100;
leadVoiceSettings.sustain = 70;
leadVoiceSettings.release = 200;
leadVoiceSettings.filterCut = 4000;
leadVoiceSettings.filterRes = 2;
leadVoiceSettings.filterType = 'lowpass';
leadVoiceSettings.vibRate = 5;
leadVoiceSettings.vibDepth = 20;
leadVoiceSettings.vibDelay = 50;
leadVoiceSettings.volume = 100;
leadVoiceSettings.pan = 0;
leadVoiceSettings.normalize = true;
leadVoiceSettings.octave = 0;
leadVoiceSettings.detune = 0;
leadVoiceSettings.drift = 0;
leadVoiceSettings.glide = 0;
leadVoiceSettings.lfoFilter = 0;
leadVoiceSettings.lfoRate = 4;
leadVoiceSettings.stereoWidth = 0;
leadVoiceSettings.delaySend = 0;
leadVoiceSettings.reverbSend = 0;
leadVoiceSettings.drive = 0;
leadVoiceSettings.chorus = 0;
leadVoiceSettings.bitCrush = 0;
leadVoiceSettings.sampleRate = 0;
// Reset applyTo (which voices deep settings affect)
leadVoiceSettings.applyTo = {
xwave: true, sid: true, chip: true, fm: true, brass: true, poly: true,
pad: false, bass: false
};
// Re-populate modal
openLeadVoiceSettings();
// Sync quick controls
syncLeadUniversalControls();
}
// WALK deep settings - nudge all sliders
// direction: -1 = all down, 1 = all up, 0 = random per slider
function walkDeepSettings(direction) {
const modal = document.getElementById('leadVoiceSettingsModal');
if (!modal) return;
const sliders = modal.querySelectorAll('input[type="range"]');
sliders.forEach(slider => {
// Skip checkboxes and certain critical params (volume, octave, pan)
const skipIds = ['lvVolume', 'lvOctave', 'lvNormalize', 'lvPan'];
if (skipIds.includes(slider.id)) return;
// Check category locks - skip if parent category is locked
const category = slider.closest('[data-category]');
if (category && category.dataset.locked === 'true') return;
const min = parseFloat(slider.min);
const max = parseFloat(slider.max);
const current = parseFloat(slider.value);
const range = max - min;
if (range <= 0) return;
// Nudge amount: 1-10% of range
const nudgePct = 0.01 + Math.random() * 0.09;
const stepSize = range * nudgePct;
// Direction: -1=down, 1=up, 0=random
const dir = direction === 0 ? (Math.random() < 0.5 ? -1 : 1) : direction;
let newValue = current + (stepSize * dir);
newValue = Math.max(min, Math.min(max, newValue));
// Handle step attribute for discrete sliders
const step = parseFloat(slider.step) || 1;
if (step >= 1) {
newValue = Math.round(newValue / step) * step;
}
slider.value = newValue;
slider.dispatchEvent(new Event('input', { bubbles: true }));
});
logEvolution(`DEEP WALK ${direction === -1 ? '◀' : direction === 1 ? '▶' : '~'}`);
}
// Toggle category lock for WALK protection
function toggleCategoryLock(btn) {
const category = btn.closest('[data-category]');
if (!category) return;
const isLocked = category.dataset.locked === 'true';
category.dataset.locked = isLocked ? 'false' : 'true';
btn.textContent = isLocked ? '🔓' : '🔒';
btn.style.color = isLocked ? '#888' : '#F44';
btn.style.borderColor = isLocked ? '#444' : '#F44';
// Dim sliders when locked
const sliders = category.querySelectorAll('input[type="range"]');
sliders.forEach(s => s.style.opacity = isLocked ? '1' : '0.5');
}
// Helper to get lead voice settings for a specific voice type
function getLeadSettingsForVoice(voiceType) {
const applyTo = leadVoiceSettings.applyTo || {
// Default: all leads enabled, sequencer voices off
xwave: true, sid: true, chip: true, fm: true, brass: true, poly: true,
pad: false, bass: false
};
const voiceMap = {
'xwave': 'xwave', 'xWave': 'xwave',
'sidLead': 'sid', 'sid': 'sid',
'chipLead': 'chip', 'chip': 'chip',
'fmWhammy': 'fm', 'fm': 'fm',
'fmPad': 'pad', 'pad': 'pad',
'fmBrass': 'brass', 'brass': 'brass',
'xpoly': 'poly', 'poly': 'poly',
'bass': 'bass', 'spaceBass': 'bass'
};
const key = voiceMap[voiceType];
// If no applyTo set yet, default to true for leads
if (!key) return null;
if (applyTo[key] === false) return null;
if (applyTo[key] === undefined) {
// Default: leads on, sequencer voices off
const isLead = ['xwave', 'sid', 'chip', 'fm', 'brass', 'poly'].includes(key);
if (!isLead) return null;
}
return leadVoiceSettings;
}
function applyPerfPadSettings() {
perfPadSettings.filterMin = parseInt(document.getElementById('ppFilterMin').value) || 80;
perfPadSettings.filterMax = parseInt(document.getElementById('ppFilterMax').value) || 20000;
perfPadSettings.resMin = parseFloat(document.getElementById('ppResMin').value) || 1;
perfPadSettings.resMax = parseFloat(document.getElementById('ppResMax').value) || 13;
perfPadSettings.delayTime = parseInt(document.getElementById('ppDelayTime').value) || 200;
perfPadSettings.velocitySens = (parseInt(document.getElementById('ppVelocitySens').value) || 50) / 100;
// Morph mode
const morphRadio = document.querySelector('input[name="ppMorphMode"]:checked');
perfPadSettings.morphMode = morphRadio ? morphRadio.value : 'ghost';
// Effect toggles
perfPadSettings.enableDelay = document.getElementById('ppEnableDelay').checked;
perfPadSettings.enableSaturation = document.getElementById('ppEnableSat').checked;
perfPadSettings.enableCrush = document.getElementById('ppEnableCrush').checked;
// Update delay time if FX chain exists
if (window.perfPadFX && window.perfPadFX.delay) {
window.perfPadFX.delay.delayTime.value = perfPadSettings.delayTime / 1000;
}
logEvolution('PERF PAD SETTINGS UPDATED');
}
function resetPerfPadSettings() {
document.getElementById('ppFilterMin').value = 80;
document.getElementById('ppFilterMax').value = 20000;
document.getElementById('ppResMin').value = 1;
document.getElementById('ppResMax').value = 13;
document.getElementById('ppDelayTime').value = 200;
document.getElementById('ppVelocitySens').value = 50;
document.getElementById('ppVelSensVal').textContent = '50%';
document.querySelector('input[name="ppMorphMode"][value="ghost"]').checked = true;
document.getElementById('ppEnableDelay').checked = true;
document.getElementById('ppEnableSat').checked = true;
document.getElementById('ppEnableCrush').checked = true;
}
let perfPadBitCrush = 1.0; // 1 = clean, 0 = crushed
let perfPadDelay = 0; // 0-1 delay mix
let perfPadDistortion = 0; // 0-1 distortion amount
function initPerfPad() {
const canvas = document.getElementById('scopeCanvas');
if (!canvas) return;
// Aggressively prevent all browser behaviors including Apple Look Up
canvas.addEventListener('contextmenu', (e) => e.preventDefault());
canvas.addEventListener('selectstart', (e) => e.preventDefault());
canvas.addEventListener('webkitmouseforcedown', (e) => e.preventDefault()); // Force Touch
canvas.addEventListener('webkitmouseforcechanged', (e) => e.preventDefault());
canvas.addEventListener('gesturestart', (e) => e.preventDefault()); // Pinch/zoom
canvas.addEventListener('gesturechange', (e) => e.preventDefault());
canvas.addEventListener('gestureend', (e) => e.preventDefault());
canvas.addEventListener('mousedown', (e) => {
e.preventDefault();
perfPadActive = true;
showPerfOverlay();
handlePerfPadMove(e);
});
canvas.addEventListener('mousemove', (e) => {
if (perfPadActive) handlePerfPadMove(e);
});
canvas.addEventListener('mouseup', () => {
perfPadActive = false;
hidePerfCursor();
hidePerfOverlay(); // Direct call
resetPerfPadEffects();
});
canvas.addEventListener('mouseleave', () => {
perfPadActive = false;
hidePerfCursor();
hidePerfOverlay(); // Direct call
resetPerfPadEffects();
});
// Touch support
canvas.addEventListener('touchstart', (e) => {
e.preventDefault();
perfPadActive = true;
showPerfOverlay();
handlePerfPadMove(e.touches[0]);
}, { passive: false });
canvas.addEventListener('touchmove', (e) => {
e.preventDefault();
if (perfPadActive) handlePerfPadMove(e.touches[0]);
}, { passive: false });
canvas.addEventListener('touchend', (e) => {
e.preventDefault();
perfPadActive = false;
hidePerfCursor();
hidePerfOverlay(); // Direct call
resetPerfPadEffects();
});
canvas.addEventListener('touchcancel', (e) => {
e.preventDefault();
perfPadActive = false;
hidePerfCursor();
hidePerfOverlay(); // Direct call
resetPerfPadEffects();
});
// Global listeners to catch release outside canvas
document.addEventListener('mouseup', () => {
if (perfPadActive) {
perfPadActive = false;
hidePerfCursor();
hidePerfOverlay();
resetPerfPadEffects();
}
});
document.addEventListener('touchend', () => {
if (perfPadActive) {
perfPadActive = false;
hidePerfCursor();
hidePerfOverlay();
resetPerfPadEffects();
}
});
}
// No overlay needed anymore - waveform always visible!
function showPerfOverlay() { } // Stub for compatibility
function hidePerfOverlay() { } // Stub for compatibility
// Global: reset effects when user clicks anywhere outside the perf pad
document.addEventListener('click', (e) => {
const scopePanel = document.getElementById('scopePanel');
if (scopePanel && !scopePanel.contains(e.target)) {
perfPadActive = false;
hidePerfCursor();
resetPerfPadEffects();
}
});
// Also reset on any key press
document.addEventListener('keydown', () => {
perfPadActive = false;
hidePerfCursor();
resetPerfPadEffects();
});
// Also reset when window loses focus
window.addEventListener('blur', () => {
perfPadActive = false;
hidePerfCursor();
resetPerfPadEffects();
});
function handlePerfPadMove(e) {
const canvas = document.getElementById('scopeCanvas');
if (!canvas) return;
const rect = canvas.getBoundingClientRect();
const x = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width)); // 0-1 clamped
const y = Math.max(0, Math.min(1, (e.clientY - rect.top) / rect.height)); // 0-1 clamped
// === VELOCITY TRACKING (uses velocitySens setting) ===
const dx = x - perfPadLastX;
const dy = y - perfPadLastY;
const distance = Math.sqrt(dx * dx + dy * dy);
const velSens = perfPadSettings.velocitySens * 16; // 0-16 multiplier based on setting
perfPadVelocity = Math.min(1, perfPadVelocity * 0.7 + distance * velSens);
perfPadLastX = x;
perfPadLastY = y;
// === RADIAL CONTROL ===
const centerX = 0.5, centerY = 0.5;
const radialDist = Math.sqrt((x - centerX) ** 2 + (y - centerY) ** 2) * 2; // 0-1.4ish
const angle = Math.atan2(y - centerY, x - centerX); // -PI to PI
// X = Scene selection with crossfade morphing
const snapshots = patternSnapshots || [];
if (snapshots.length > 0) {
const exactPos = x * snapshots.length;
const sceneIndex = Math.floor(exactPos);
const morphAmount = exactPos - sceneIndex;
const clampedIndex = Math.max(0, Math.min(snapshots.length - 1, sceneIndex));
if (clampedIndex !== perfPadCurrentScene) {
perfPadCurrentScene = clampedIndex;
loadSnapshotToSeq(clampedIndex);
logEvolution(`SCENE ${clampedIndex + 1}`);
}
if (morphAmount > 0.1 && clampedIndex < snapshots.length - 1) {
morphSceneVelocities(clampedIndex, clampedIndex + 1, morphAmount);
}
}
// === GOD MODE EFFECTS ===
// Quadrant-based effect routing:
// TOP-LEFT: Resonance + Delay
// TOP-RIGHT: Resonance + Distortion
// BOTTOM-LEFT: Cutoff + Bit Crush
// BOTTOM-RIGHT: Cutoff + Distortion
const intensity = Math.min(1, radialDist + perfPadVelocity * 0.5); // Distance + speed boost
if (y < 0.5) {
// Upper half: resonance
const normalized = 1 - (y / 0.5);
const curved = Math.pow(normalized, 2.5) * (1 + perfPadVelocity);
perfPadResonance = Math.min(1, 0.5 + curved * 0.5);
perfPadFilterCutoff = 1.0;
if (x < 0.5) {
// TOP-LEFT: Delay increases (gentle)
perfPadDelay = Math.pow(normalized, 3) * 0.5; // Cubic curve, max 50%
perfPadDistortion = 0;
} else {
// TOP-RIGHT: Distortion increases (very subtle, slow ramp)
perfPadDistortion = Math.pow(normalized, 4) * 0.25; // Quartic curve, max 25%
perfPadDelay = 0;
}
perfPadBitCrush = 1.0;
} else {
// Lower half: cutoff
const normalized = (y - 0.5) / 0.5;
const curved = Math.pow(normalized, 3) * (1 + perfPadVelocity * 0.3);
perfPadResonance = 0.5;
perfPadFilterCutoff = Math.max(0.05, 1.0 - curved * 0.95);
if (x < 0.5) {
// BOTTOM-LEFT: Bit crush (gentle lo-fi)
perfPadBitCrush = Math.max(0.3, 1.0 - Math.pow(normalized, 3) * 0.7);
perfPadDistortion = 0;
} else {
// BOTTOM-RIGHT: Subtle distortion + darker filter
perfPadDistortion = Math.pow(normalized, 4) * 0.2; // Very subtle
perfPadBitCrush = 1.0;
}
perfPadDelay = 0;
}
// Apply all effects
applyPerfPadEffects();
// Show cursor
showPerfCursor(x, y);
}
function applyPerfPadFilter() {
// Legacy - now use applyPerfPadEffects
applyPerfPadEffects();
}
function applyPerfPadEffects() {
const ctx = initAudio();
if (!ctx || !musicGain) return;
// === CREATE EFFECT CHAIN IF NEEDED ===
if (!window.perfPadFX) {
// Filter
const filter = ctx.createBiquadFilter();
filter.type = 'lowpass';
filter.frequency.value = 20000;
filter.Q.value = 1;
// Soft saturation (gentle waveshaper)
const saturation = ctx.createWaveShaper();
saturation.curve = makeSoftSaturationCurve(0);
saturation.oversample = '4x'; // Higher quality
// Pre-saturation gain (drive)
const driveGain = ctx.createGain();
driveGain.gain.value = 1;
// Post-saturation makeup gain (compensate for saturation)
const makeupGain = ctx.createGain();
makeupGain.gain.value = 1;
// Delay
const delay = ctx.createDelay(1.0);
delay.delayTime.value = 0.2; // Slightly shorter
const delayFeedback = ctx.createGain();
delayFeedback.gain.value = 0.2;
const delayMix = ctx.createGain();
delayMix.gain.value = 0;
const dryGain = ctx.createGain();
dryGain.gain.value = 1;
// Bit crusher (lo-fi filter)
const bitCrushFilter = ctx.createBiquadFilter();
bitCrushFilter.type = 'lowpass';
bitCrushFilter.frequency.value = 20000;
// Limiter (compressor set as limiter)
const limiter = ctx.createDynamicsCompressor();
limiter.threshold.value = -3; // Start limiting at -3dB
limiter.knee.value = 0; // Hard knee
limiter.ratio.value = 20; // Heavy limiting
limiter.attack.value = 0.001; // Fast attack
limiter.release.value = 0.1; // Quick release
// Wire: musicGain -> filter -> drive -> saturation -> makeup -> [dry + delay] -> crush -> limiter -> analyser -> out
musicGain.disconnect();
musicGain.connect(filter);
// Reconnect to recording destination (critical for WebM export!)
if (recordingDest) musicGain.connect(recordingDest);
filter.connect(driveGain);
driveGain.connect(saturation);
saturation.connect(makeupGain);
makeupGain.connect(dryGain);
makeupGain.connect(delay);
delay.connect(delayFeedback);
delayFeedback.connect(delay);
delay.connect(delayMix);
dryGain.connect(bitCrushFilter);
delayMix.connect(bitCrushFilter);
bitCrushFilter.connect(limiter);
// Connect through analyser so oscilloscope still works!
limiter.connect(analyser);
analyser.disconnect(); // Disconnect from old destination
analyser.connect(ctx.destination);
window.perfPadFX = { filter, saturation, driveGain, makeupGain, delay, delayFeedback, delayMix, dryGain, bitCrushFilter, limiter };
}
const fx = window.perfPadFX;
const t = ctx.currentTime;
const s = perfPadSettings; // Shorthand
// === FILTER (uses settings for min/max) ===
const filterRange = s.filterMax - s.filterMin;
const cutoffHz = s.filterMin + perfPadFilterCutoff * filterRange;
fx.filter.frequency.setTargetAtTime(cutoffHz, t, 0.08);
const qRange = s.resMax - s.resMin;
const q = s.resMin + perfPadResonance * qRange;
fx.filter.Q.setTargetAtTime(q, t, 0.08);
// === SATURATION (respects enable toggle) ===
if (s.enableSaturation && perfPadDistortion > 0.01) {
const drive = 1 + perfPadDistortion * 2;
fx.driveGain.gain.setTargetAtTime(drive, t, 0.15);
fx.saturation.curve = makeSoftSaturationCurve(perfPadDistortion);
const makeup = 1 / Math.sqrt(drive);
fx.makeupGain.gain.setTargetAtTime(makeup, t, 0.15);
} else {
fx.driveGain.gain.setTargetAtTime(1, t, 0.15);
fx.saturation.curve = makeSoftSaturationCurve(0);
fx.makeupGain.gain.setTargetAtTime(1, t, 0.15);
}
// === DELAY (respects enable toggle and delay time setting) ===
if (s.enableDelay && perfPadDelay > 0.01) {
fx.delay.delayTime.setTargetAtTime(s.delayTime / 1000, t, 0.1);
fx.delayMix.gain.setTargetAtTime(perfPadDelay * 0.4, t, 0.1);
fx.delayFeedback.gain.setTargetAtTime(0.15 + perfPadDelay * 0.25, t, 0.1);
} else {
fx.delayMix.gain.setTargetAtTime(0, t, 0.1);
fx.delayFeedback.gain.setTargetAtTime(0.15, t, 0.1);
}
// === BIT CRUSH (respects enable toggle) ===
if (s.enableCrush && perfPadBitCrush < 0.99) {
const crushFreq = 2000 + perfPadBitCrush * 18000;
fx.bitCrushFilter.frequency.setTargetAtTime(crushFreq, t, 0.08);
} else {
fx.bitCrushFilter.frequency.setTargetAtTime(20000, t, 0.08);
}
}
// Soft saturation curve - warm analog-style, no harsh clipping
function makeSoftSaturationCurve(amount) {
const samples = 512; // Higher resolution
const curve = new Float32Array(samples);
for (let i = 0; i < samples; i++) {
const x = (i * 2) / samples - 1;
if (amount <= 0.01) {
// Clean - linear
curve[i] = x;
} else {
// Soft saturation using sigmoid-like curve
// Amount 0-1 controls how much saturation
const k = 1 + amount * 3; // Gentle curve factor
curve[i] = Math.tanh(x * k) / Math.tanh(k); // Normalized tanh
}
}
return curve;
}
// Legacy distortion curve (kept for reference)
function makeDistortionCurve(amount) {
return makeSoftSaturationCurve(amount / 400); // Convert old scale
}
function resetPerfPadEffects() {
perfPadFilterCutoff = 1.0;
perfPadResonance = 0.5;
perfPadBitCrush = 1.0;
perfPadDelay = 0;
perfPadDistortion = 0;
perfPadVelocity = 0;
applyPerfPadEffects();
}
function showPerfCursor(x, y) {
const cursor = document.getElementById('scopeCursor');
const canvas = document.getElementById('scopeCanvas');
if (!cursor || !canvas) return;
// Vertical line cursor - follows X position, full height
cursor.style.display = 'block';
cursor.style.left = (x * canvas.offsetWidth - 6) + 'px'; // Center 12px wide cursor
cursor.style.top = '0';
// Show FX indicator when effects are active
const fxIndicator = document.getElementById('scopeEffectIndicator');
if (fxIndicator) {
const hasEffects = perfPadDistortion > 0.01 || perfPadDelay > 0.01 || perfPadBitCrush < 0.99 || perfPadResonance > 0.6 || perfPadFilterCutoff < 0.9;
fxIndicator.style.opacity = hasEffects ? '1' : '0';
}
}
function hidePerfCursor() {
const cursor = document.getElementById('scopeCursor');
if (cursor) cursor.style.display = 'none';
const fxIndicator = document.getElementById('scopeEffectIndicator');
if (fxIndicator) fxIndicator.style.opacity = '0';
}
// Morph velocities between two scenes for crossfade effect
function morphSceneVelocities(sceneA, sceneB, amount) {
const snapshots = patternSnapshots || [];
if (!snapshots[sceneA] || !snapshots[sceneB]) return;
if (!breakbeat || !breakbeat.patterns) return;
const patternsA = snapshots[sceneA].patterns;
const patternsB = snapshots[sceneB].patterns;
const useGhostNotes = perfPadSettings.morphMode === 'ghost';
// Morph each voice's pattern
Object.keys(breakbeat.patterns).forEach(voice => {
const patA = patternsA[voice];
const patB = patternsB[voice];
if (!patA || !patB) return;
for (let i = 0; i < 16; i++) {
const velA = patA[i] || 0;
const velB = patB[i] || 0;
let newVel;
if (useGhostNotes) {
// Ghost mode: lerp between velocities (creates ghost notes at low velocities)
newVel = Math.round(velA * (1 - amount) + velB * amount);
} else {
// Real mode: hard switch at 50% threshold (no ghost notes)
newVel = amount < 0.5 ? velA : velB;
}
breakbeat.patterns[voice][i] = newVel;
// Mark as user-painted so it shows colored in the sequencer grid
if (newVel > 0) {
seqUserPainted[`${voice}-${i}`] = true;
}
}
});
// Sync seqPatterns to breakbeat patterns for visual update
seqPatterns = breakbeat.patterns;
ensureVoiceIndependence(); // CRITICAL: Decouple all voices for per-voice motifs
}
function drawScopeSceneMarkers() {
const container = document.getElementById('scopeSceneMarkers');
if (!container) return;
const snapshots = patternSnapshots || [];
if (snapshots.length === 0) {
container.innerHTML = '';
return;
}
let html = '';
snapshots.forEach((snap, i) => {
const isActive = i === perfPadCurrentScene;
// Compact scene markers at bottom - just colored bars
html += `<div style="flex:1;background:${isActive ? 'rgba(255,0,255,0.4)' : 'rgba(255,255,255,0.05)'};border-right:1px solid rgba(0,0,0,0.5);display:flex;align-items:center;justify-content:center;">
<span style="color:${isActive ? '#FFF' : '#555'};font-size:6px;font-weight:${isActive ? 'bold' : 'normal'};">${i + 1}</span>
</div>`;
});
container.innerHTML = html;
}
function startSeqAnimation() {
function animate(timestamp) {
// Drift is now applied on step boundaries in scheduler(), not here
renderSeqCanvas();
renderSeqGarden();
updatePlayhead();
drawScope();
// Update live displays periodically (every 500ms)
if (timestamp - lastSeqInfoUpdate > 500) {
updateSeqInfo();
lastSeqInfoUpdate = timestamp;
}
// Plant flower every bar
if (breakbeat && breakbeat.barCount !== seqLastBarForFlower) {
seqLastBarForFlower = breakbeat.barCount;
// Snapshot current patterns for rewind
const snapshot = {};
for (const key of Object.keys(seqPatterns || {})) {
if (Array.isArray(seqPatterns[key])) {
snapshot[key] = [...seqPatterns[key]];
}
}
plantFlower('flower', snapshot);
}
seqAnimFrame = requestAnimationFrame(animate);
}
animate(0);
}
const MAX_VOICES = 64;
// HSL colors by voice type
const VOICE_TYPE_HSL = {
kick: { h: 0, s: 80, lBase: 35 }, // Red
snare: { h: 30, s: 90, lBase: 40 }, // Orange
hihat: { h: 50, s: 80, lBase: 45 }, // Yellow
bass: { h: 120, s: 70, lBase: 35 }, // Green
pad: { h: 180, s: 60, lBase: 40 }, // Cyan
clap: { h: 300, s: 80, lBase: 40 }, // Magenta
perc: { h: 160, s: 70, lBase: 38 }, // Teal
rimshot: { h: 15, s: 70, lBase: 42 }, // Coral
shaker: { h: 45, s: 75, lBase: 50 }, // Gold
cowbell: { h: 35, s: 85, lBase: 45 }, // Amber
tom: { h: 10, s: 75, lBase: 38 }, // Dark red
conga: { h: 25, s: 80, lBase: 42 }, // Brown-orange
lead: { h: 270, s: 80, lBase: 50 }, // Purple
stab: { h: 320, s: 75, lBase: 45 }, // Pink
whammy: { h: 200, s: 85, lBase: 45 }, // Sky blue
xpoly: { h: 240, s: 70, lBase: 50 }, // Blue
sub: { h: 330, s: 100, lBase: 50 }, // Hot pink
acid: { h: 180, s: 100, lBase: 50 }, // Bright Cyan
pop: { h: 50, s: 100, lBase: 60 } // Bright Yellow
};
function toggleShowAllVoices() {
seqShowAllVoices = !seqShowAllVoices;
renderSeqCanvas();
}
function updateShowAllVoicesButton(totalVoices, visibleCount) {
const btn = document.getElementById('showAllVoicesBtn');
if (!btn) return;
const hiddenCount = totalVoices - visibleCount;
if (totalVoices <= SEQ_DEFAULT_VISIBLE_VOICES) {
btn.style.display = 'none';
} else {
btn.style.display = 'block';
if (seqShowAllVoices) {
btn.textContent = `▲ HIDE EXTRA VOICES (${hiddenCount} hidden)`;
btn.style.color = '#0AF';
btn.style.borderColor = '#0AF';
} else {
btn.textContent = `▼ SHOW ALL VOICES (+${hiddenCount} more)`;
btn.style.color = '#666';
btn.style.borderColor = '#333';
}
}
}
function renderSeqCanvas() {
if (!seqCtx || !seqPatterns) return;
// Ensure canvas buffer stays at correct size (can get corrupted on resize)
if (seqCanvas.width !== 640) seqCanvas.width = 640;
if (seqCanvas.height !== 160) seqCanvas.height = 160;
const W = 640;
const allVoices = voiceList.slice(0, MAX_VOICES);
// Show only first N voices unless expanded
const visibleCount = seqShowAllVoices ? allVoices.length : Math.min(SEQ_DEFAULT_VISIBLE_VOICES, allVoices.length);
const voices = allVoices.slice(0, visibleCount);
const numVoices = voices.length;
// Update show all button visibility
updateShowAllVoicesButton(allVoices.length, visibleCount);
// Resize canvas height based on voice count (min 5 rows)
const rowH = 24; // Slightly smaller rows for better fit
const H = Math.max(120, numVoices * rowH);
if (seqCanvas.height !== H) {
seqCanvas.height = H;
// Update canvas display height to match (not stretched)
seqCanvas.style.height = H + 'px';
}
const stepW = W / 16;
// Clear with dark background
seqCtx.fillStyle = '#080810';
seqCtx.fillRect(0, 0, W, H);
// Draw beat markers
for (let i = 0; i < 16; i++) {
if (i % 4 === 0) {
seqCtx.fillStyle = 'rgba(255,0,255,0.1)';
seqCtx.fillRect(i * stepW, 0, stepW, H);
}
}
// Draw step numbers 1-16 at top
seqCtx.font = '9px IBM Plex Mono, monospace';
seqCtx.textAlign = 'center';
for (let i = 0; i < 16; i++) {
// Brighter on beat 1, dimmer on others
seqCtx.fillStyle = i % 4 === 0 ? 'rgba(255,0,255,0.6)' : 'rgba(150,150,150,0.5)';
seqCtx.fillText(String(i + 1), i * stepW + stepW / 2, 10);
}
seqCtx.textAlign = 'left'; // Reset
// Render each voice from voiceList
voices.forEach((voice, vIdx) => {
const voiceId = voice.id;
const voiceType = voice.type;
// Look up pattern by voiceId first, then by base type, then empty
let pattern = seqPatterns[voiceId];
if (!pattern || pattern.length === 0) {
pattern = seqPatterns[voiceType];
}
if (!pattern) pattern = [];
const hsl = VOICE_TYPE_HSL[voiceType] || { h: 0, s: 50, lBase: 40 };
const y = vIdx * rowH;
const firstStepFilled = pattern[0] > 0;
// Draw ghost notes - ethereal white glow that fades in/out
// Ghost = note in breakbeat.patterns that user hasn't explicitly painted
// Only show ghosts on the first voice of each type (to avoid duplicates)
const firstOfTypeIdx = voiceList.findIndex(v => v.type === voiceType);
const isFirstOfType = firstOfTypeIdx >= 0 && voiceList[firstOfTypeIdx].id === voiceId;
if (isFirstOfType && breakbeat && breakbeat.patterns && breakbeat.patterns[voiceType] && !seqGhostMute) {
const ghostPattern = breakbeat.patterns[voiceType];
if (!Array.isArray(ghostPattern)) return; // Safety check
for (let step = 0; step < 16; step++) {
const ghostVal = ghostPattern[step];
// Check if user explicitly painted this cell (by voiceType OR voiceId)
const userPaintedByType = seqUserPainted && seqUserPainted[`${voiceType}-${step}`];
const userPaintedById = seqUserPainted && seqUserPainted[`${voiceId}-${step}`];
const userPainted = userPaintedByType || userPaintedById;
// Show ghost if there's a value and user didn't paint it
if (ghostVal > 0 && !userPainted) {
const x = step * stepW;
// Check if this ghost note just triggered
const triggerKey = `${voiceType}-${step}`;
const triggerTime = ghostTriggers[triggerKey] || 0;
const age = Date.now() - triggerTime;
// Ethereal pulse - slow breathing effect + flash on trigger (25% brighter)
const breathe = 0.4 + Math.sin(Date.now() / 800 + step * 0.5) * 0.2; // Increased base visibility
const flashDecay = Math.max(0, 1 - age / 400); // Fade over 400ms
const alpha = Math.min(1, breathe + flashDecay * 0.6);
// White glow core - brighter and more visible
seqCtx.fillStyle = `rgba(255, 255, 255, ${alpha * 0.9})`; // Increased from 0.7
seqCtx.fillRect(x + 4, y + 4, stepW - 8, rowH - 8);
// Outer ethereal glow - more visible
seqCtx.fillStyle = `rgba(220, 240, 255, ${alpha * 0.6})`; // Increased from 0.4
seqCtx.fillRect(x + 2, y + 2, stepW - 4, rowH - 4);
// Bright flash when just triggered
if (age < 150) {
seqCtx.fillStyle = `rgba(255, 255, 255, ${0.8 - age / 200})`;
seqCtx.fillRect(x + 2, y + 2, stepW - 4, rowH - 4);
// Halo glow
seqCtx.fillStyle = `rgba(255, 255, 255, ${0.3 - age / 500})`;
seqCtx.fillRect(x, y, stepW, rowH);
}
}
}
}
for (let step = 0; step < 16; step++) {
const val = pattern[step];
// Only draw colored cell if USER painted it (not algorithmic ghost)
// Use voiceId so secondary voices render independently
const paintKey = `${voiceId}-${step}`;
const userPainted = seqUserPainted && seqUserPainted[paintKey];
if (val > 0 && userPainted) {
const x = step * stepW;
// For bass/pad: brightness based on pitch (higher = brighter)
let intensity;
if (voiceType === 'bass' || voiceType === 'pad' || voiceType === 'sub') {
const noteInScale = (val - (voiceType === 'bass' || voiceType === 'sub' ? 36 : 60)) % 12;
intensity = 0.3 + (noteInScale / 12) * 0.4;
} else {
intensity = Math.min(val, 1) * 0.7;
}
// User cells: darker, more saturated, grounded
// Ghost cells stay bright/ethereal at top of monochrome scale
const lightness = hsl.lBase * 0.6 + intensity * 25;
const saturation = Math.min(hsl.s + 15, 100);
// Draw main cell (user-painted = solid, darker, grounded)
seqCtx.fillStyle = `hsla(${hsl.h}, ${saturation}%, ${lightness}%, 0.95)`;
seqCtx.fillRect(x + 2, y + 2, stepW - 4, rowH - 4);
// Subtle inner highlight (not bright glow)
seqCtx.fillStyle = `hsla(${hsl.h}, ${saturation}%, ${lightness + 10}%, 0.2)`;
seqCtx.fillRect(x + 3, y + 3, stepW - 6, rowH - 6);
}
}
// Voice label - black if 1st step filled, otherwise colored
seqCtx.fillStyle = firstStepFilled ? '#000' : `hsl(${hsl.h}, ${hsl.s}%, 60%)`;
seqCtx.font = 'bold 8px IBM Plex Mono, monospace';
seqCtx.fillText(voice.name, 4, y + 11);
// Draw p-lock indicators (small dot in corner)
for (let step = 0; step < 16; step++) {
const key = `${voiceId}-${step}`;
if (seqPlocks[key]) {
const x = step * stepW;
seqCtx.fillStyle = '#FFF';
seqCtx.beginPath();
seqCtx.arc(x + stepW - 6, y + 6, 3, 0, Math.PI * 2);
seqCtx.fill();
}
}
});
// Grid lines
seqCtx.strokeStyle = 'rgba(255,255,255,0.1)';
seqCtx.lineWidth = 1;
for (let i = 1; i < 16; i++) {
seqCtx.beginPath();
seqCtx.moveTo(i * stepW, 0);
seqCtx.lineTo(i * stepW, H);
seqCtx.stroke();
}
for (let i = 1; i < numVoices; i++) {
seqCtx.beginPath();
seqCtx.moveTo(0, i * rowH);
seqCtx.lineTo(W, i * rowH);
seqCtx.stroke();
}
}
function updatePlayhead() {
if (!breakbeat || !breakbeat.isPlaying) return;
const playhead = document.getElementById('seqPlayhead');
if (!playhead) return;
const step = breakbeat.currentStep || 0;
const pct = (step / 16) * 100;
playhead.style.left = pct + '%';
// Match playhead height to canvas height (for expanded voice view)
if (seqCanvas) {
playhead.style.height = seqCanvas.style.height || seqCanvas.height + 'px';
}
// Update live BPM display
const bpmLive = document.getElementById('seqBpmLive');
if (bpmLive && breakbeat.bpm) {
bpmLive.textContent = Math.round(breakbeat.bpm);
}
}
function paintAtMouse(e, isFirstClick) {
if (!seqPatterns) return;
const rect = seqCanvas.getBoundingClientRect();
const x = (e.clientX - rect.left) / rect.width;
const y = (e.clientY - rect.top) / rect.height;
const step = Math.floor(x * 16);
// Use visible voices (same as renderSeqCanvas)
const allVoices = voiceList.slice(0, MAX_VOICES);
const visibleCount = seqShowAllVoices ? allVoices.length : Math.min(SEQ_DEFAULT_VISIBLE_VOICES, allVoices.length);
const voices = allVoices.slice(0, visibleCount);
const numVoices = voices.length;
// Match the EXACT row calculation from renderSeqCanvas
const rowH = 24;
const canvasH = seqCanvas.height; // Use actual canvas buffer height
const yPixel = y * canvasH;
const voiceIdx = Math.min(Math.floor(yPixel / rowH), numVoices - 1);
const targetVoiceObj = voices[voiceIdx];
if (step < 0 || step > 15 || !targetVoiceObj) return;
const voiceId = targetVoiceObj.id;
const voiceType = targetVoiceObj.type;
const cellKey = `${voiceId}-${step}`;
// Ensure pattern array exists for this voice
if (!seqPatterns[voiceId]) {
seqPatterns[voiceId] = new Array(16).fill(0);
}
// On first click, determine paint mode based on current cell state
if (isFirstClick) {
const currentVal = seqPatterns[voiceId][step];
seqPaintMode = currentVal > 0 ? 'off' : 'on';
}
// Helper to paint a single step
const paintStep = (s) => {
const pk = `${voiceId}-${s}`;
const ck = `${voiceId}-${s}`;
if (seqPaintedCells.has(ck)) return;
seqPaintedCells.add(ck);
// If clicking on a ghost step (exists in breakbeat.patterns but not user-painted),
// clear it from breakbeat.patterns first so it stops showing as ghost
if (breakbeat && breakbeat.patterns && breakbeat.patterns[voiceType] &&
breakbeat.patterns[voiceType][s] > 0 && !seqUserPainted[`${voiceType}-${s}`]) {
// Clear the ghost from breakbeat.patterns
breakbeat.patterns[voiceType][s] = 0;
}
// Drums: Y position controls pitch (click higher in row = higher pitch)
const drumTypes = ['kick', 'snare', 'hihat', 'clap', 'perc', 'shaker', 'cowbell', 'tom', 'conga', 'rimshot', 'pop'];
if (drumTypes.includes(voiceType)) {
if (seqPaintMode === 'off') {
seqPatterns[voiceId][s] = 0;
delete seqUserPainted[pk];
// Also clear from breakbeat.patterns if it exists there
if (breakbeat && breakbeat.patterns && breakbeat.patterns[voiceType]) {
breakbeat.patterns[voiceType][s] = 0;
}
} else {
const yInCell = (y * numVoices) - voiceIdx;
const pitchMult = 0.5 + (1 - yInCell);
seqPatterns[voiceId][s] = pitchMult;
seqUserPainted[pk] = true;
// Mark as user-painted by voiceType too (for ghost rendering)
seqUserPainted[`${voiceType}-${s}`] = true;
}
}
// Melodic: set note or clear based on paint mode
const melodicTypes = ['bass', 'pad', 'lead', 'stab', 'whammy', 'xpoly', 'sub', 'acid'];
if (melodicTypes.includes(voiceType)) {
if (seqPaintMode === 'off') {
seqPatterns[voiceId][s] = 0;
delete seqUserPainted[pk];
// Also clear from breakbeat.patterns if it exists there
if (breakbeat && breakbeat.patterns && breakbeat.patterns[voiceType]) {
breakbeat.patterns[voiceType][s] = 0;
}
} else {
const scale = SCALE_INTERVALS[seqScale] || SCALE_INTERVALS.minor;
const root = (voiceType === 'bass' || voiceType === 'sub' || voiceType === 'acid') ? 36 : 60;
const yInCell = (y * numVoices) - voiceIdx;
const noteIdx = Math.floor((1 - yInCell) * scale.length);
const note = root + (scale[Math.min(noteIdx, scale.length - 1)] || 0);
seqPatterns[voiceId][s] = note;
seqUserPainted[pk] = true;
// Mark as user-painted by voiceType too (for ghost rendering)
seqUserPainted[`${voiceType}-${s}`] = true;
}
}
};
// Paint the clicked step
paintStep(step);
// Shift+click: also paint mirrored step (opposite side of row)
if (e.shiftKey) {
const mirrorStep = 15 - step;
if (mirrorStep !== step) {
paintStep(mirrorStep);
}
}
}
function setDrift(voiceId, dir) {
seqDrift[voiceId] = dir;
seqDriftCounters[voiceId] = 0; // Reset counter on direction change
renderDriftControls();
}
// Sync volume between drift controls and voice config modal
function setVoiceVolume(id, value) {
const vol = parseInt(value);
seqVolume[id] = vol;
const v = voiceList.find(x => x.id === id);
if (v) {
v.volume = vol;
if (breakbeat && breakbeat.voiceConfig) {
if (!breakbeat.voiceConfig[id]) breakbeat.voiceConfig[id] = {};
breakbeat.voiceConfig[id].volume = vol;
}
}
}
// Legacy setDrift button handler (for any remaining hardcoded buttons)
function setDriftLegacy(voice, dir) {
seqDrift[voice] = dir;
seqDriftCounters[voice] = 0;
const cap = voice.charAt(0).toUpperCase() + voice.slice(1);
const lBtn = document.getElementById('drift' + cap + 'L');
const sBtn = document.getElementById('drift' + cap + 'S');
const rBtn = document.getElementById('drift' + cap + 'R');
if (lBtn) {
lBtn.style.background = dir === 'left' ? '#F0F' : '#222';
lBtn.style.color = dir === 'left' ? '#000' : '#888';
lBtn.style.borderColor = dir === 'left' ? '#F0F' : '#333';
lBtn.className = dir === 'left' ? 'drift-active' : '';
}
if (sBtn) {
sBtn.style.background = dir === 'none' ? '#F0F' : '#222';
sBtn.style.color = dir === 'none' ? '#000' : '#888';
sBtn.style.borderColor = dir === 'none' ? '#F0F' : '#333';
sBtn.className = '';
}
if (rBtn) {
rBtn.style.background = dir === 'right' ? '#F0F' : '#222';
rBtn.style.color = dir === 'right' ? '#000' : '#888';
rBtn.style.borderColor = dir === 'right' ? '#F0F' : '#333';
rBtn.className = dir === 'right' ? 'drift-active' : '';
}
}
// P-Lock functions
function openPlockAtMouse(e) {
const rect = seqCanvas.getBoundingClientRect();
const x = (e.clientX - rect.left) / rect.width;
const y = (e.clientY - rect.top) / rect.height;
const step = Math.floor(x * 16);
// Use visible voices (same as paintAtMouse/renderSeqCanvas)
const allVoices = voiceList.slice(0, MAX_VOICES);
const visibleCount = seqShowAllVoices ? allVoices.length : Math.min(SEQ_DEFAULT_VISIBLE_VOICES, allVoices.length);
const voices = allVoices.slice(0, visibleCount);
const numVoices = voices.length;
// Match the EXACT row calculation from renderSeqCanvas
const rowH = 24;
const canvasH = seqCanvas.height;
const yPixel = y * canvasH;
const voiceIdx = Math.min(Math.floor(yPixel / rowH), numVoices - 1);
const voiceObj = voices[voiceIdx];
const voice = voiceObj ? voiceObj.id : null;
if (step < 0 || step > 15 || !voice) return;
currentPlockVoice = voice;
currentPlockStep = step;
const modal = document.getElementById('seqPlockModal');
const key = `${voice}-${step}`;
const plock = seqPlocks[key] || { vel: 80, prob: 100, pitch: 0, nudge: 0, ratchet: 1 };
// Save state for undo
plockUndoState = seqPlocks[key] ? { ...seqPlocks[key] } : null;
document.getElementById('plockTitle').textContent = `STEP ${step + 1} - ${voice.toUpperCase()}`;
document.getElementById('plockVel').value = plock.vel;
document.getElementById('plockProb').value = plock.prob;
document.getElementById('plockPitch').value = plock.pitch || 0;
document.getElementById('plockNudge').value = plock.nudge || 0;
document.getElementById('plockRatchet').value = plock.ratchet || 1;
// Position modal near click (fixed positioning = viewport coords)
modal.style.left = Math.min(e.clientX + 10, window.innerWidth - 200) + 'px';
modal.style.top = Math.min(e.clientY + 10, window.innerHeight - 180) + 'px';
modal.style.display = 'block';
}
function updatePlockRealtime() {
if (currentPlockVoice === null || currentPlockStep === null) return;
const key = `${currentPlockVoice}-${currentPlockStep}`;
const vel = parseInt(document.getElementById('plockVel').value) || 80;
const prob = parseInt(document.getElementById('plockProb').value) || 100;
const pitch = parseInt(document.getElementById('plockPitch').value) || 0;
const nudge = parseInt(document.getElementById('plockNudge').value) || 0;
const ratchet = parseInt(document.getElementById('plockRatchet').value) || 1;
// Apply immediately
seqPlocks[key] = { vel, prob, pitch, nudge, ratchet };
}
function undoPlock() {
if (currentPlockVoice === null || currentPlockStep === null) return;
const key = `${currentPlockVoice}-${currentPlockStep}`;
if (plockUndoState) {
// Restore to state when modal opened
seqPlocks[key] = { ...plockUndoState };
document.getElementById('plockVel').value = plockUndoState.vel;
document.getElementById('plockProb').value = plockUndoState.prob;
document.getElementById('plockPitch').value = plockUndoState.pitch || 0;
document.getElementById('plockNudge').value = plockUndoState.nudge || 0;
document.getElementById('plockRatchet').value = plockUndoState.ratchet || 1;
} else {
// No previous state = remove p-lock entirely
delete seqPlocks[key];
document.getElementById('plockVel').value = 80;
document.getElementById('plockProb').value = 100;
document.getElementById('plockPitch').value = 0;
document.getElementById('plockNudge').value = 0;
document.getElementById('plockRatchet').value = 1;
}
// Flash feedback
const modal = document.getElementById('seqPlockModal');
modal.style.background = '#330';
setTimeout(() => modal.style.background = '#111', 150);
renderSeqCanvas();
}
function closePlockModal() {
document.getElementById('seqPlockModal').style.display = 'none';
currentPlockVoice = null;
currentPlockStep = null;
plockUndoState = null;
}
function clearPlock() {
if (currentPlockVoice === null || currentPlockStep === null) return;
const key = `${currentPlockVoice}-${currentPlockStep}`;
delete seqPlocks[key];
// Reset to defaults
document.getElementById('plockVel').value = 80;
document.getElementById('plockProb').value = 100;
document.getElementById('plockPitch').value = 0;
document.getElementById('plockNudge').value = 0;
document.getElementById('plockRatchet').value = 1;
// Flash feedback and close
const modal = document.getElementById('seqPlockModal');
modal.style.background = '#030';
setTimeout(() => {
modal.style.background = '#111';
closePlockModal();
}, 150);
renderSeqCanvas(); // Update visual to remove p-lock dot
}
function applyDrift() {
if (!seqPatterns) return;
// Apply drift to all voices in voiceList
voiceList.forEach(v => {
const voiceId = v.id;
const dir = seqDrift[voiceId];
if (!dir || dir === 'none') return;
// Ensure pattern exists
if (!seqPatterns[voiceId]) return;
// Get speed from state (1-8), convert to threshold
const speed = seqDriftSpeed[voiceId] || 4;
// Increment counter, only drift when counter reaches threshold
if (!seqDriftCounters[voiceId]) seqDriftCounters[voiceId] = 0;
seqDriftCounters[voiceId]++;
const threshold = 9 - speed;
if (seqDriftCounters[voiceId] < threshold) return;
seqDriftCounters[voiceId] = 0;
if (dir === 'left') {
const first = seqPatterns[voiceId].shift();
seqPatterns[voiceId].push(first);
} else if (dir === 'right') {
const last = seqPatterns[voiceId].pop();
seqPatterns[voiceId].unshift(last);
}
});
}
function renderSnapshotList() {
const container = document.getElementById('snapshotList');
if (!container) return;
container.innerHTML = '';
// Update the external LIVE button state instead of creating one here
const liveBtn = document.getElementById('seqLiveBtn');
if (liveBtn) {
liveBtn.style.background = selectedSnapshotIdx === -1 ? 'rgb(0 255 0 / 60%)' : 'rgb(255 170 0 / 60%)';
liveBtn.style.borderColor = selectedSnapshotIdx === -1 ? '#0F0' : '#FA0';
liveBtn.textContent = selectedSnapshotIdx === -1 ? 'LIVE ●' : 'LIVE';
liveBtn.onclick = () => loadLiveToSeq();
}
if (patternSnapshots.length === 0) {
const hint = document.createElement('span');
hint.textContent = '(patterns auto-save as you play)';
hint.style.cssText = 'color:#444;font-size:8px;margin-left:8px;';
container.appendChild(hint);
}
// Only show last 4 snapshots
const recentSnapshots = patternSnapshots.slice(-4);
const startIdx = patternSnapshots.length - recentSnapshots.length;
recentSnapshots.forEach((snap, i) => {
const actualIdx = startIdx + i;
const btn = document.createElement('button');
btn.textContent = `B${snap.bar}`;
btn.title = snap.reason || 'auto';
btn.style.cssText = `padding:4px 8px;font-size:9px;background:${selectedSnapshotIdx === actualIdx ? '#F0F' : '#222'};color:${selectedSnapshotIdx === actualIdx ? '#000' : '#F0F'};border:1px solid #F0F;cursor:pointer;font-family:'IBM Plex Mono',monospace;`;
btn.onclick = () => loadSnapshotToSeq(actualIdx);
container.appendChild(btn);
});
// Update performance pad scene markers
drawScopeSceneMarkers();
}
function loadLiveToSeq() {
selectedSnapshotIdx = -1;
if (breakbeat && breakbeat.patterns) {
seqPatterns = breakbeat.patterns; // Direct reference - LIVE!
ensureVoiceIndependence(); // CRITICAL: Decouple all voices for per-voice motifs
seqBpm = breakbeat.bpm || 90;
seqKitName = breakbeat.currentKitName || '';
seqScale = musicSettings.scale || 'minor';
}
updateSeqInfo();
renderSnapshotList();
}
function loadSnapshotToSeq(idx) {
const snap = patternSnapshots[idx];
if (!snap || !breakbeat) return;
selectedSnapshotIdx = idx;
// Copy snapshot directly into live patterns - INSTANT!
breakbeat.patterns.kick = [...snap.patterns.kick];
breakbeat.patterns.snare = [...snap.patterns.snare];
breakbeat.patterns.hihat = [...snap.patterns.hihat];
breakbeat.patterns.bass = [...snap.patterns.bass];
breakbeat.patterns.pad = [...snap.patterns.pad];
seqPatterns = breakbeat.patterns;
ensureVoiceIndependence(); // CRITICAL: Decouple all voices for per-voice motifs
if (!seqBpmLocked) {
seqBpm = snap.bpm || 90;
breakbeat.bpm = seqBpm;
}
seqKitName = snap.kitName || '';
seqScale = snap.scale || 'minor';
logEvolution(`REWIND to B${snap.bar}`);
updateSeqInfo();
renderSnapshotList();
}
function updateSeqInfo() {
const bpmEl = document.getElementById('seqBpmInput');
const kitEl = document.getElementById('seqKitSelect');
const scaleEl = document.getElementById('seqScaleSelect');
const bpmMinEl = document.getElementById('seqBpmMin');
const bpmMaxEl = document.getElementById('seqBpmMax');
const lockBtn = document.getElementById('bpmLockBtn');
const kitLiveEl = document.getElementById('seqKitLive');
const motifLiveEl = document.getElementById('seqMotifLive');
const ghostProbEl = document.getElementById('seqGhostProb');
const evolveEl = document.getElementById('seqEvolveRate');
// Sync seqBpm with actual breakbeat BPM (source of truth)
if (breakbeat && breakbeat.bpm) {
seqBpm = breakbeat.bpm;
}
// Update BPM display with actual value (don't overwrite if user is editing)
if (bpmEl && document.activeElement !== bpmEl) bpmEl.value = Math.round(seqBpm);
if (kitEl) kitEl.value = seqKitName || '';
if (scaleEl) scaleEl.value = seqScale || 'minor';
if (bpmMinEl && musicSettings) bpmMinEl.value = musicSettings.bpmMin;
if (bpmMaxEl && musicSettings) bpmMaxEl.value = musicSettings.bpmMax;
if (ghostProbEl && document.activeElement !== ghostProbEl) ghostProbEl.value = seqGhostProb;
if (evolveEl && document.activeElement !== evolveEl) evolveEl.value = seqEvolveRate;
if (lockBtn) {
// Don't overwrite seqBpmLocked - just update the UI to match current state
lockBtn.textContent = seqBpmLocked ? '🔒' : '🔓';
lockBtn.style.background = '#222';
lockBtn.style.borderColor = seqBpmLocked ? '#F0F' : '#555';
lockBtn.style.color = seqBpmLocked ? '#F0F' : '#888';
}
// Update live kit/motif displays
if (kitLiveEl && breakbeat) {
const kitName = breakbeat.currentKitName || seqKitName || '';
// Shorten kit name for display
const shortKit = kitName ? kitName.replace(/^ARCADE |^SYNTH |^MACHINES |^TR-|^CR-|^JUNO-|^TB-/, '').split(' ')[0] : 'rand';
kitLiveEl.textContent = shortKit;
kitLiveEl.title = kitName || 'Random kit';
}
if (motifLiveEl && breakbeat) {
// Get current motif voice from breakbeat.motif
const motifVoice = (breakbeat.motif && breakbeat.motif.voice) ? breakbeat.motif.voice : 'rand';
// Shorten voice name
const shortMotif = motifVoice.replace(/^x/, '').replace(/^fm/, '').replace(/Lead$/, '');
motifLiveEl.textContent = shortMotif;
motifLiveEl.title = 'Current motif: ' + motifVoice;
}
}
// BPM Lock toggle
function toggleBpmLock() {
seqBpmLocked = !seqBpmLocked;
const btn = document.getElementById('bpmLockBtn');
if (btn) {
btn.textContent = seqBpmLocked ? '🔒' : '🔓';
btn.style.background = '#222';
btn.style.borderColor = seqBpmLocked ? '#F0F' : '#555';
btn.style.color = seqBpmLocked ? '#F0F' : '#888';
}
logEvolution(seqBpmLocked ? 'BPM LOCKED' : 'BPM UNLOCKED');
}
// Helper to set BPM respecting the lock
function setBreakbeatBpm(newBpm, forceOverride = false) {
if (seqBpmLocked && !forceOverride) return false; // Locked, don't change
if (breakbeat) breakbeat.bpm = newBpm;
seqBpm = newBpm;
return true;
}
function applySeqSetting(type, value) {
const el = event?.target;
applySeqSettingNow(type, value, el);
}
function applySeqSettingNow(type, value, el) {
// Flash feedback
if (el) {
const origBorder = el.style.borderColor;
el.style.borderColor = '#FFF';
el.style.boxShadow = '0 0 8px ' + origBorder;
setTimeout(() => { el.style.borderColor = origBorder; el.style.boxShadow = 'none'; }, 200);
}
switch (type) {
case 'bpm':
// User setting BPM anchor
seqBpm = parseInt(value) || 90;
seqBpm = Math.max(30, Math.min(300, seqBpm)); // Hard limits only
if (breakbeat) {
if (seqBpmLocked) {
// LOCKED: Immediately apply (user is in full control)
breakbeat.bpm = seqBpm;
breakbeat.targetBpm = seqBpm;
breakbeat.pendingBpm = null;
} else {
// UNLOCKED: Set as target for gradual drift (anchor)
breakbeat.targetBpm = seqBpm;
breakbeat.pendingBpm = null;
// Note: breakbeat.bpm will drift toward this naturally
}
}
// Auto-expand range if user goes outside it
if (musicSettings) {
if (seqBpm < musicSettings.bpmMin) musicSettings.bpmMin = seqBpm;
if (seqBpm > musicSettings.bpmMax) musicSettings.bpmMax = seqBpm;
}
// Debounce BPM logging
clearTimeout(window.bpmLogTimeout);
window.bpmLogTimeout = setTimeout(() => {
logEvolution(seqBpmLocked ? `BPM → ${seqBpm}` : `BPM anchor → ${seqBpm}`);
}, 150);
break;
case 'kit':
seqKitName = value;
if (breakbeat) breakbeat.currentKitName = value;
if (value) {
logEvolution(`KIT → ${value.split(' ')[0]}`);
// Plant a bush when kit changes (dancers sidestep!)
plantFlower('bush', null);
const kits = getAvailableKits();
const kit = kits.find(k => k.name === value);
if (kit) {
// Set kitOverrideName so randomizePatterns() respects this kit!
breakbeat.kitOverrideName = value;
// Reset kit rotation counter when manually selecting a kit
// This prevents the kit from immediately changing due to rotation
if (breakbeat.kitRotation) {
breakbeat.kitRotation.barsOnCurrentKit = 0;
breakbeat.kitRotation.isRandom = false;
}
// Load all kit patterns
if (kit.kick) breakbeat.patterns.kick = [...kit.kick];
if (kit.snare) breakbeat.patterns.snare = [...kit.snare];
if (kit.noise) breakbeat.patterns.hihat = [...kit.noise];
if (kit.bass) breakbeat.patterns.bass = [...kit.bass];
if (kit.lead) breakbeat.patterns.lead = [...kit.lead];
if (kit.pad) breakbeat.patterns.pad = [...kit.pad];
logEvolution(`PATTERNS LOADED (${Object.keys(kit).filter(k => Array.isArray(kit[k])).length} voices) [LOCKED]`);
// Set kit's BPM as drift target (no instant jump)
if (!seqBpmLocked && kit.bpm) {
breakbeat.targetBpm = kit.bpm;
logEvolution(`BPM drift target → ${kit.bpm}`);
}
}
} else {
// "(Random)" selected - clear the kit override
if (breakbeat) {
breakbeat.kitOverrideName = null;
// Reset rotation state for random mode
if (breakbeat.kitRotation) {
breakbeat.kitRotation.barsOnCurrentKit = 0;
breakbeat.kitRotation.isRandom = true;
}
}
logEvolution('KIT → (Random)');
}
break;
case 'scale':
seqScale = value;
if (musicSettings) musicSettings.scale = value;
logEvolution(`SCALE → ${value}`);
break;
case 'motif':
seqMotifVoice = value;
if (musicSettings) musicSettings.motifVoice = value;
if (value) logEvolution(`MOTIF → ${value}`);
// Show/hide wavetable panel based on voice selection
updateXWavePanelVisibility();
// When header lead dropdown is used, switch motif panel to LEAD and sync
const voiceSelector = document.getElementById('voicePanelSelector');
if (voiceSelector && voiceSelector.value !== 'lead') {
// Switch panel to LEAD first
voiceSelector.value = 'lead';
switchVoicePanel('lead');
}
// Now sync the mode dropdown
const newSelect = document.getElementById('voicePanelMode');
if (newSelect) {
newSelect.value = value || '';
updateLeadVoicePanel();
}
break;
case 'bars':
seqMotifBars = parseInt(value);
// -1 means LOCK (never change motif)
// 0 means RANDOM (S&H style - pick random phrase each time)
if (seqMotifBars === -1) {
logEvolution('MOTIF PHRASE → LOCKED');
} else if (seqMotifBars === 0) {
if (musicSettings) musicSettings.motifBars = 0; // Signal random mode
logEvolution('MOTIF PHRASE → RANDOM');
} else {
seqMotifBars = seqMotifBars || 4;
if (musicSettings) musicSettings.motifBars = seqMotifBars;
logEvolution(`MOTIF PHRASE → ${seqMotifBars} bars`);
}
break;
case 'kitPhrase':
const kitPhraseVal = parseInt(value);
seqKitPhrase = kitPhraseVal; // Save to variable
// -1 means LOCK (never auto-change kit)
if (kitPhraseVal === -1) {
musicSettings.kitStaleMax = -1; // LOCK
logEvolution('KIT PHRASE → LOCKED');
} else {
musicSettings.kitStaleMax = kitPhraseVal || 32;
logEvolution(`KIT PHRASE → ${kitPhraseVal} bars`);
}
// Sync with music settings slider if visible
const kitStaleSlider = document.getElementById('musicKitStaleMax');
const kitStaleVal = document.getElementById('musicKitStaleMaxVal');
if (kitStaleSlider && kitPhraseVal !== -1) {
kitStaleSlider.value = musicSettings.kitStaleMax;
if (kitStaleVal) kitStaleVal.textContent = musicSettings.kitStaleMax;
}
break;
case 'swing':
const swingVal = parseInt(value) || 0;
if (breakbeat) breakbeat.swing = swingVal / 100;
document.getElementById('seqSwingVal').textContent = swingVal + '%';
break;
case 'bpmMin':
const minVal = parseInt(value) || 60;
if (musicSettings) {
musicSettings.bpmMin = minVal;
if (musicSettings.bpmMin > musicSettings.bpmMax) {
musicSettings.bpmMax = musicSettings.bpmMin;
document.getElementById('seqBpmMax').value = musicSettings.bpmMax;
}
// Clamp current BPM to new range
if (breakbeat && breakbeat.bpm < musicSettings.bpmMin) {
breakbeat.bpm = musicSettings.bpmMin;
seqBpm = breakbeat.bpm;
}
}
logEvolution(`BPM MIN → ${minVal}`);
break;
case 'bpmMax':
const maxVal = parseInt(value) || 180;
if (musicSettings) {
musicSettings.bpmMax = maxVal;
if (musicSettings.bpmMax < musicSettings.bpmMin) {
musicSettings.bpmMin = musicSettings.bpmMax;
document.getElementById('seqBpmMin').value = musicSettings.bpmMin;
}
// Clamp current BPM to new range
if (breakbeat && breakbeat.bpm > musicSettings.bpmMax) {
breakbeat.bpm = musicSettings.bpmMax;
seqBpm = breakbeat.bpm;
}
}
logEvolution(`BPM MAX → ${maxVal}`);
break;
}
}
// === GHOST PATTERN CONTROLS ===
function toggleGhostMute() {
seqGhostMute = !seqGhostMute;
const btn = document.getElementById('ghostMuteBtn');
if (btn) {
btn.style.background = seqGhostMute ? '#444' : '#222';
btn.style.borderColor = seqGhostMute ? '#F44' : '#FFF';
btn.style.color = seqGhostMute ? '#F44' : '#FFF';
btn.textContent = seqGhostMute ? 'MUTED' : 'GHOSTS';
}
// When toggling ghosts OFF, clear userPainted flags for steps that are ghosts
// This prevents double-click issue where ghost steps can't be clicked immediately
if (seqGhostMute && breakbeat && breakbeat.patterns && seqUserPainted) {
const ghostVoices = ['kick', 'snare', 'hihat', 'bass', 'pad'];
ghostVoices.forEach(v => {
if (breakbeat.patterns[v]) {
const voice = voiceList.find(x => x.type === v);
if (voice && seqPatterns[voice.id]) {
for (let i = 0; i < 16; i++) {
// If breakbeat has a note but seqPatterns doesn't, it's a ghost
// Clear userPainted flag so it can be clicked
if (breakbeat.patterns[v][i] > 0 && seqPatterns[voice.id][i] === 0) {
const key = `${v}-${i}`;
if (seqUserPainted[key]) {
delete seqUserPainted[key];
}
}
}
}
}
});
renderSeqCanvas(); // Refresh display
}
logEvolution(seqGhostMute ? 'GHOSTS MUTED' : 'GHOSTS ON');
}
function randomizeGhostPatterns() {
if (!breakbeat || !breakbeat.patterns) return;
// Randomize all ghost patterns
breakbeat.patterns.rimshot = new Array(16).fill(0).map(() => Math.random() < 0.08 ? 1 : 0);
breakbeat.patterns.shaker = new Array(16).fill(0).map((_, i) => (i % 2 === 1 && Math.random() < 0.3) ? 1 : 0);
breakbeat.patterns.cowbell = new Array(16).fill(0).map(() => Math.random() < 0.06 ? 1 : 0);
breakbeat.patterns.tom = new Array(16).fill(0).map((_, i) => (i >= 12 && Math.random() < 0.15) ? 1 : 0);
breakbeat.patterns.conga = new Array(16).fill(0).map(() => Math.random() < 0.1 ? 1 : 0);
// Core drums too
breakbeat.patterns.kick = new Array(16).fill(0).map((_, i) => (i % 4 === 0 || Math.random() < 0.08) ? 1 : 0);
breakbeat.patterns.snare = new Array(16).fill(0).map((_, i) => ((i === 4 || i === 12) || Math.random() < 0.05) ? 1 : 0);
breakbeat.patterns.hihat = new Array(16).fill(0).map(() => Math.random() < 0.85 ? 1 : 0);
breakbeat.patterns.clap = new Array(16).fill(0).map((_, i) => (i === 4 || i === 12) ? 1 : 0);
// Enhanced bass generation - expanded note palette + rhythmic variety
const scale = SCALE_INTERVALS[musicSettings.scale] || SCALE_INTERVALS.minor;
const root = 36;
// Expanded note palette: scale notes + octaves + chromatic passing tones
const expandedBassNotes = [
...scale.map(i => root - 12 + i), // Octave below
...scale.map(i => root + i), // Root octave
...scale.map(i => root + 12 + i), // Octave above
...[1, 2, 5, 6, 11].map(i => root + i) // Chromatic passing tones
];
// Rhythmic templates for variety
const rhythmicTemplates = [
[1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0], // Quarter notes (classic)
[1, 0, 1, 0, 0, 1, 0, 1, 1, 0, 1, 0, 0, 1, 0, 1], // Syncopated
[1, 0, 0, 1, 0, 0, 1, 0, 1, 0, 0, 1, 0, 0, 1, 0], // Off-beat emphasis
[1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1], // Sparse
[1, 1, 0, 0, 0, 1, 1, 0, 0, 0, 1, 1, 0, 0, 0, 1], // Funky
];
// Choose rhythmic template
const rhythm = rhythmicTemplates[Math.floor(Math.random() * rhythmicTemplates.length)];
// Generate bassline with melodic flow
breakbeat.patterns.bass = new Array(16).fill(0).map((shouldPlay, i) => {
if (!rhythm[i]) return 0;
// 70% scale notes, 20% octaves, 10% chromatic
const rand = Math.random();
let notePool;
if (rand < 0.7) {
notePool = scale.map(i => root + i); // Scale notes
} else if (rand < 0.9) {
notePool = [root - 12, root, root + 12]; // Octaves
} else {
notePool = [1, 2, 5, 6, 11].map(i => root + i); // Chromatic
}
return notePool[Math.floor(Math.random() * notePool.length)];
});
logEvolution('GHOSTS REROLLED');
}
function setGhostProb(val) {
seqGhostProb = parseInt(val) || 0;
try {
localStorage.setItem('seqGhostProb_v2', String(seqGhostProb));
localStorage.removeItem('seqGhostProb');
} catch (e) { }
}
function setEvolveRate(val) {
seqEvolveRate = parseInt(val) || 0;
// Update musicSettings if available
if (musicSettings) musicSettings.evolveRate = seqEvolveRate;
try {
localStorage.setItem('seqEvolveRate_v2', String(seqEvolveRate));
localStorage.removeItem('seqEvolveRate');
} catch (e) { }
}
function toggleSeqLogExpand() {
const expanded = document.getElementById('seqLogExpanded');
const btn = document.getElementById('seqLogExpandBtn');
if (expanded && btn) {
const isHidden = expanded.style.display === 'none';
expanded.style.display = isHidden ? 'block' : 'none';
btn.textContent = isHidden ? 'LESS' : 'MORE';
}
}
function startSeqGraduation() {
if (!breakbeat || !breakbeat.patterns) return;
// Don't start graduation if ghost mute is on - no hits will be tracked anyway
if (seqGhostMute) return;
const bars = Math.max(1, parseInt(seqMotifBars) || 4);
seqGraduation.active = true;
seqGraduation.startBar = (breakbeat.barCount || 0);
seqGraduation.totalBars = bars;
seqGraduation.lastBar = -1;
seqGraduation.stage = 0;
seqGraduation.hits = {};
}
function promoteHitStepsToUser(voiceType, minHits) {
if (!breakbeat || !breakbeat.patterns) return false;
const pat = breakbeat.patterns[voiceType];
if (!pat || !Array.isArray(pat)) return false;
let changed = false;
const need = Math.max(1, parseInt(minHits) || 1);
for (let i = 0; i < 16; i++) {
if (!pat[i]) continue;
const hitKey = `${voiceType}-${i}`;
const hits = (seqGraduation.hits && typeof seqGraduation.hits[hitKey] === 'number') ? seqGraduation.hits[hitKey] : 0;
if (hits >= need && !seqUserPainted[hitKey]) {
seqUserPainted[hitKey] = true;
changed = true;
}
}
return changed;
}
function advanceSeqGraduationIfNeeded(step) {
if (!seqGraduation.active || !breakbeat) return;
// Don't advance graduation if ghost mute is on - no hits are being tracked
if (seqGhostMute) {
seqGraduation.active = false;
return;
}
if (typeof isSequencerOpen === 'function' && !isSequencerOpen()) return;
if (step !== 0) return;
const bar = (breakbeat.barCount || 0);
if (seqGraduation.lastBar === bar) return;
seqGraduation.lastBar = bar;
const elapsed = bar - seqGraduation.startBar;
if (elapsed < 0) return;
const total = Math.max(1, seqGraduation.totalBars || 1);
const b1 = 0;
const b2 = Math.max(0, Math.floor(total * 0.25));
const b3 = Math.max(0, Math.floor(total * 0.5));
const b4 = Math.max(0, Math.floor(total * 0.75));
// Stages: kick -> snare/clap -> bass -> hats/perc -> pad
let stageWanted = 0;
if (elapsed >= b1) stageWanted = 1;
if (elapsed >= b2) stageWanted = 2;
if (elapsed >= b3) stageWanted = 3;
if (elapsed >= b4) stageWanted = 4;
if (elapsed >= Math.max(0, total - 1)) stageWanted = 5;
if (stageWanted > seqGraduation.stage) seqGraduation.stage = stageWanted;
// Build-up strength is controlled by ghost probability (lower prob = fewer hits).
// Require consistency across the phrase: promote steps that hit on >= half the phrase bars.
const minHits = Math.max(1, Math.ceil(total * 0.5));
let changed = false;
if (seqGraduation.stage >= 1) {
changed = promoteHitStepsToUser('kick', minHits) || changed;
}
if (seqGraduation.stage >= 2) {
changed = promoteHitStepsToUser('snare', minHits) || changed;
changed = promoteHitStepsToUser('clap', minHits) || changed;
}
if (seqGraduation.stage >= 3) {
changed = promoteHitStepsToUser('bass', minHits) || changed;
}
if (seqGraduation.stage >= 4) {
changed = promoteHitStepsToUser('hihat', minHits) || changed;
changed = promoteHitStepsToUser('rimshot', minHits) || changed;
changed = promoteHitStepsToUser('shaker', minHits) || changed;
changed = promoteHitStepsToUser('cowbell', minHits) || changed;
changed = promoteHitStepsToUser('tom', minHits) || changed;
changed = promoteHitStepsToUser('conga', minHits) || changed;
}
if (seqGraduation.stage >= 5) {
changed = promoteHitStepsToUser('pad', minHits) || changed;
seqGraduation.active = false;
}
if (changed) renderSeqCanvas();
}
function syncPatternsToGhost() {
// SYNC: Force-apply selected kit and motif voice from dropdowns
if (!breakbeat || !breakbeat.patterns) return;
// 1. Get selected kit from dropdown
const kitSelect = document.getElementById('seqKitSelect');
const selectedKitName = kitSelect ? kitSelect.value : seqKitName;
// 2. Get selected motif voice from dropdown
const motifSelect = document.getElementById('seqMotifVoice');
const selectedMotifVoice = motifSelect ? motifSelect.value : seqMotifVoice;
// 3. If kit is selected, load its patterns
if (selectedKitName) {
const kits = getAvailableKits();
const kit = kits.find(k => k.name === selectedKitName);
if (kit) {
// Apply kit patterns to breakbeat (ensure they're arrays)
if (kit.kick) {
breakbeat.patterns.kick = Array.isArray(kit.kick) ? [...kit.kick] : Array(16).fill(0);
}
if (kit.snare) {
breakbeat.patterns.snare = Array.isArray(kit.snare) ? [...kit.snare] : Array(16).fill(0);
}
if (kit.noise) {
breakbeat.patterns.hihat = Array.isArray(kit.noise) ? [...kit.noise] : Array(16).fill(0);
}
if (kit.bass) {
breakbeat.patterns.bass = Array.isArray(kit.bass) ? [...kit.bass] : Array(16).fill(0);
}
if (kit.lead) {
breakbeat.patterns.lead = Array.isArray(kit.lead) ? [...kit.lead] : Array(16).fill(0);
}
// Kit BPM: Set as target for gradual drift (no instant jump)
// Only when unlocked - drift system will gradually transition
if (kit.bpm && !seqBpmLocked) {
breakbeat.targetBpm = kit.bpm; // Drift toward kit's tempo
logEvolution(`BPM drift target → ${kit.bpm}`);
}
seqKitName = selectedKitName;
breakbeat.currentKitName = selectedKitName;
logEvolution(`KIT APPLIED → ${selectedKitName.split(' ')[1] || selectedKitName}`);
}
}
// 4. Apply selected motif voice
if (selectedMotifVoice) {
seqMotifVoice = selectedMotifVoice;
if (musicSettings) musicSettings.motifVoice = selectedMotifVoice;
if (breakbeat.motif) breakbeat.motif.voice = selectedMotifVoice;
logEvolution(`MOTIF VOICE → ${selectedMotifVoice}`);
}
// 5. Sync patterns to visual sequencer - preserve user-painted steps, show kit as ghosts elsewhere
// SYNC = "sync to kit" - keep user edits, clear non-user steps so kit patterns show as ghosts
voiceList.forEach(voice => {
if (!seqPatterns || !seqPatterns[voice.id]) return;
// For each step: preserve if user-painted, otherwise clear so it shows as ghost from breakbeat.patterns
for (let i = 0; i < 16; i++) {
const key = `${voice.id}-${i}`;
const keyType = `${voice.type}-${i}`;
const isUserPainted = seqUserPainted && (seqUserPainted[key] || seqUserPainted[keyType]);
if (!isUserPainted) {
// Not user-painted: clear so it shows as ghost from breakbeat.patterns
seqPatterns[voice.id][i] = 0;
// If ghosts are muted, also clear breakbeat.patterns to prevent fallback
if (seqGhostMute && breakbeat && breakbeat.patterns && breakbeat.patterns[voice.type]) {
breakbeat.patterns[voice.type][i] = 0;
}
}
// If user-painted, keep the existing value in seqPatterns (don't touch it)
}
});
logEvolution('SYNCED PATTERNS');
updateSeqInfo();
renderSeqCanvas();
}
// Track ghost note trigger for flash effect
function markGhostTrigger(voice, step) {
ghostTriggers[`${voice}-${step}`] = Date.now();
}
function toggleMute(voiceId) {
seqMute[voiceId] = !seqMute[voiceId];
updateMuteSoloState();
renderDriftControls();
}
function toggleSolo(voiceId) {
seqSolo[voiceId] = !seqSolo[voiceId];
updateMuteSoloState();
renderDriftControls();
}
function updateMuteSoloState() {
// Check if any voice is soloed
const anySolo = Object.values(seqSolo).some(v => v);
// Track type-level mute/solo (for ghost notes which use types like 'kick' not 'kick0')
const typeMuted = {};
const typeSoloed = {};
voiceList.forEach(voice => {
const voiceId = voice.id;
const type = voice.type;
let shouldPlay = true;
if (seqMute[voiceId]) {
shouldPlay = false;
} else if (anySolo && !seqSolo[voiceId]) {
shouldPlay = false;
}
// Store in breakbeat for the scheduler to use
if (breakbeat) {
if (!breakbeat.voiceMute) breakbeat.voiceMute = {};
if (!breakbeat.voiceSolo) breakbeat.voiceSolo = {};
breakbeat.voiceMute[voiceId] = !shouldPlay;
breakbeat.voiceSolo[voiceId] = !!seqSolo[voiceId];
// Type-level: muted if ALL voices of this type are muted
// soloed if ANY voice of this type is soloed
if (typeMuted[type] === undefined) typeMuted[type] = true;
if (!seqMute[voiceId]) typeMuted[type] = false;
if (seqSolo[voiceId]) typeSoloed[type] = true;
}
});
// Set type-level mute/solo for ghost patterns
if (breakbeat) {
Object.keys(typeMuted).forEach(type => {
// Type is muted only if ALL voices of that type are muted
breakbeat.voiceMute[type] = typeMuted[type];
// Type is soloed if ANY voice of that type is soloed
breakbeat.voiceSolo[type] = !!typeSoloed[type];
});
}
}
// Voice types with colors
const VOICE_TYPES = {
kick: { color: '#F44', synth: 'kick' },
snare: { color: '#FA4', synth: 'snare' },
hihat: { color: '#FF4', synth: 'hihat' },
bass: { color: '#4F4', synth: 'bass' },
pad: { color: '#4FF', synth: 'pad' },
clap: { color: '#F4F', synth: 'clap' },
perc: { color: '#4FA', synth: 'perc' },
// New drums
shaker: { color: '#FA8', synth: 'shaker' },
cowbell: { color: '#FF8', synth: 'cowbell' },
tom: { color: '#F88', synth: 'tom' },
conga: { color: '#F8A', synth: 'conga' },
// New melodic
lead: { color: '#F0F', synth: 'lead' },
stab: { color: '#8F8', synth: 'stab' },
whammy: { color: '#88F', synth: 'whammy' },
xpoly: { color: '#A8F', synth: 'xpoly' },
// Special 14th voice - versatile sub
sub: { color: '#F08', synth: 'sub' },
// TB-303 Acid Bass
acid: { color: '#0FF', synth: 'acid' },
// Drum Pop - Cross stick / rimshot accent
pop: { color: '#FFA', synth: 'pop' }
};
// Dynamic voice list - can have multiple of same type
let voiceList = [
{ id: 'kick0', type: 'kick', name: 'KICK0', volume: 100, pan: 0, pitch: 0, decay: 50 },
{ id: 'snare0', type: 'snare', name: 'SNARE0', volume: 100, pan: 0, pitch: 0, decay: 50 },
{ id: 'hihat0', type: 'hihat', name: 'HIHAT0', volume: 100, pan: 0, pitch: 0, decay: 50 },
{ id: 'bass0', type: 'bass', name: 'BASS0', volume: 100, pan: 0, pitch: 0, decay: 50 },
{ id: 'pad0', type: 'pad', name: 'PAD0', volume: 100, pan: 0, pitch: 0, decay: 50 },
{ id: 'lead0', type: 'lead', name: 'LEAD0', volume: 100, pan: 0, pitch: 0, decay: 50 }
];
function openVoiceConfig() {
document.getElementById('voiceConfigOverlay').style.display = 'flex';
renderVoiceConfigList();
}
function closeVoiceConfig() {
document.getElementById('voiceConfigOverlay').style.display = 'none';
}
function getNextVoiceId(type) {
const existing = voiceList.filter(v => v.type === type);
return `${type}${existing.length}`;
}
function addVoice(type) {
if (voiceList.length >= MAX_VOICES) {
logEvolution(`MAX ${MAX_VOICES} VOICES`);
return;
}
const id = getNextVoiceId(type);
const typeInfo = VOICE_TYPES[type];
voiceList.push({
id,
type,
name: id.toUpperCase(),
volume: 100,
pan: 0,
pitch: 0,
decay: 50
});
// Add pattern array for new voice
if (seqPatterns) {
seqPatterns[id] = new Array(16).fill(0);
}
if (breakbeat && breakbeat.patterns) {
breakbeat.patterns[id] = new Array(16).fill(0);
}
// Add drift/mute/solo state
seqDrift[id] = 'none';
seqDriftSpeed[id] = 4;
seqDriftCounters[id] = 0;
seqMute[id] = false;
seqSolo[id] = false;
renderVoiceConfigList();
renderDriftControls();
logEvolution(`+ ${id.toUpperCase()}`);
}
function deleteVoice(id) {
const idx = voiceList.findIndex(v => v.id === id);
if (idx === -1) return;
// Only protect the original 5 required voices
const requiredVoices = ['kick0', 'snare0', 'hihat0', 'bass0', 'pad0', 'lead0'];
if (requiredVoices.includes(id)) {
logEvolution(`${id.toUpperCase()} REQUIRED`);
return;
}
voiceList.splice(idx, 1);
// Remove pattern
if (seqPatterns && seqPatterns[id]) {
delete seqPatterns[id];
}
if (breakbeat && breakbeat.patterns && breakbeat.patterns[id]) {
delete breakbeat.patterns[id];
}
// Clean up drift/mute/solo state
delete seqDrift[id];
delete seqDriftSpeed[id];
delete seqDriftCounters[id];
delete seqMute[id];
delete seqSolo[id];
renderVoiceConfigList();
renderDriftControls();
logEvolution(`- ${id.toUpperCase()}`);
}
function renderDriftControls() {
const container = document.getElementById('seqDriftControls');
if (!container) return;
// Show up to 12 voices in the drift controls
const visibleVoices = voiceList.slice(0, 12);
// The original 6 required voices that cannot be deleted
const requiredVoices = ['kick0', 'snare0', 'hihat0', 'bass0', 'pad0', 'lead0'];
let html = '';
visibleVoices.forEach(v => {
const t = VOICE_TYPES[v.type] || { color: '#888' };
const isMuted = seqMute[v.id];
const isSoloed = seqSolo[v.id];
const drift = seqDrift[v.id] || 'none';
const isRequired = requiredVoices.includes(v.id);
const canDelete = !isRequired; // Can delete any voice except the original 5
html += `
<div class="voice-ctrl" style="background:#111;padding:6px;border-radius:4px;text-align:center;min-width:0;">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:4px;">
<div style="display:flex;gap:2px;">
<button onclick="toggleMute('${v.id}')" style="padding:2px 4px;background:${isMuted ? '#F44' : '#222'};border:1px solid ${isMuted ? '#F44' : '#333'};color:${isMuted ? '#000' : '#888'};cursor:pointer;font-size:7px;" title="Mute">M</button>
<button onclick="toggleSolo('${v.id}')" style="padding:2px 4px;background:${isSoloed ? '#FF0' : '#222'};border:1px solid ${isSoloed ? '#FF0' : '#333'};color:${isSoloed ? '#000' : '#888'};cursor:pointer;font-size:7px;" title="Solo">S</button>
</div>
<span style="color:${t.color};font-size:8px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">${v.name}</span>
</div>
<div style="display:flex;gap:2px;margin-bottom:4px;">
<button onclick="setDrift('${v.id}','left')" style="flex:1;padding:3px;background:${drift === 'left' ? 'rgb(255 0 255 / 60%)' : '#222'};border:1px solid ${drift === 'left' ? '#F0F' : '#333'};color:${drift === 'left' ? '#000' : '#888'};cursor:pointer;font-size:9px;">◀</button>
<button onclick="setDrift('${v.id}','none')" style="flex:1;padding:3px;background:${drift === 'none' ? 'rgb(255 0 255 / 60%)' : '#222'};border:1px solid ${drift === 'none' ? '#F0F' : '#333'};color:${drift === 'none' ? '#000' : '#888'};cursor:pointer;font-size:6px;">■</button>
<button onclick="setDrift('${v.id}','right')" style="flex:1;padding:3px;background:${drift === 'right' ? 'rgb(255 0 255 / 60%)' : '#222'};border:1px solid ${drift === 'right' ? '#F0F' : '#333'};color:${drift === 'right' ? '#000' : '#888'};cursor:pointer;font-size:9px;">▶</button>
</div>
<div style="display:flex;gap:2px;align-items:center;margin-bottom:2px;" title="Volume">
<span style="color:#4F4;font-size:6px;">V</span>
<input type="range" min="0" max="100" value="${seqVolume[v.id] ?? v.volume ?? 100}" oninput="setVoiceVolume('${v.id}',this.value)" style="flex:1;min-width:0;height:6px;accent-color:#4F4;">
</div>
<div style="display:flex;gap:2px;align-items:center;">
<span style="color:#F0F;font-size:6px;">D</span>
<input type="range" min="1" max="8" value="${seqDriftSpeed[v.id] || 4}" oninput="seqDriftSpeed['${v.id}']=parseInt(this.value)" style="flex:1;min-width:0;height:6px;accent-color:#F0F;" title="Drift Speed">
${isRequired ? `<button onclick="addVoice('${v.type}')" style="padding:1px 4px;background:#040;border:1px solid #4F4;color:#4F4;cursor:pointer;font-size:8px;flex-shrink:0;" title="Add ${v.type}">+</button>` : `<button onclick="deleteVoice('${v.id}')" style="padding:1px 4px;background:#400;border:1px solid #F44;color:#F44;cursor:pointer;font-size:8px;flex-shrink:0;" title="Remove">−</button>`}
</div>
</div>`;
});
// Add indicator if more voices exist
if (voiceList.length > 12) {
html += `<div style="background:#222;padding:6px;border-radius:4px;text-align:center;color:#666;font-size:8px;">+${voiceList.length - 12} more<br>(see ⚙️)</div>`;
}
container.innerHTML = html;
}
function renderVoiceConfigList() {
const container = document.getElementById('voiceConfigList');
// Add voice buttons
let html = `<div style="display:flex;gap:4px;margin-bottom:10px;flex-wrap:wrap;">`;
Object.keys(VOICE_TYPES).forEach(type => {
const t = VOICE_TYPES[type];
html += `<button onclick="addVoice('${type}')" style="padding:4px 8px;background:#222;border:1px solid ${t.color};color:${t.color};font-size:8px;cursor:pointer;">+ ${type.toUpperCase()}</button>`;
});
html += `</div>`;
// Voice list - original 6 are required
const requiredVoices = ['kick0', 'snare0', 'hihat0', 'bass0', 'pad0', 'lead0'];
voiceList.forEach(v => {
const t = VOICE_TYPES[v.type] || { color: '#888' };
const canDelete = !requiredVoices.includes(v.id);
html += `
<div style="background:#111;padding:8px;margin-bottom:6px;border-radius:4px;border-left:3px solid ${t.color};">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:6px;">
<span style="color:${t.color};font-size:10px;font-weight:bold;">${v.name}</span>
${canDelete ? `<button onclick="deleteVoice('${v.id}')" style="background:#400;border:1px solid #F44;color:#F44;font-size:8px;padding:2px 6px;cursor:pointer;">✕</button>` : '<span style="color:#666;font-size:7px;">REQUIRED</span>'}
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:6px;">
<div>
<div style="color:#888;font-size:7px;margin-bottom:2px;">VOL</div>
<div style="display:flex;align-items:center;gap:4px;">
<input type="range" min="0" max="150" value="${v.volume}"
oninput="updateVoice('${v.id}','volume',this.value)" style="flex:1;height:6px;">
<span style="color:#FFF;font-size:8px;width:24px;">${v.volume}%</span>
</div>
</div>
<div>
<div style="color:#888;font-size:7px;margin-bottom:2px;">PAN</div>
<div style="display:flex;align-items:center;gap:4px;">
<input type="range" min="-100" max="100" value="${v.pan}"
oninput="updateVoice('${v.id}','pan',this.value)" style="flex:1;height:6px;">
<span style="color:#FFF;font-size:8px;width:24px;">${v.pan > 0 ? 'R' + v.pan : v.pan < 0 ? 'L' + Math.abs(v.pan) : 'C'}</span>
</div>
</div>
<div>
<div style="color:#888;font-size:7px;margin-bottom:2px;">PITCH</div>
<div style="display:flex;align-items:center;gap:4px;">
<input type="range" min="-12" max="12" value="${v.pitch}"
oninput="updateVoice('${v.id}','pitch',this.value)" style="flex:1;height:6px;">
<span style="color:#FFF;font-size:8px;width:24px;">${v.pitch > 0 ? '+' + v.pitch : v.pitch}</span>
</div>
</div>
<div>
<div style="color:#888;font-size:7px;margin-bottom:2px;">DECAY</div>
<div style="display:flex;align-items:center;gap:4px;">
<input type="range" min="10" max="100" value="${v.decay}"
oninput="updateVoice('${v.id}','decay',this.value)" style="flex:1;height:6px;">
<span style="color:#FFF;font-size:8px;width:24px;">${v.decay}%</span>
</div>
</div>
</div>
<div style="display:flex;gap:8px;margin-top:8px;align-items:center;border-top:1px solid #333;padding-top:8px;">
<div style="color:#F0F;font-size:7px;">DRIFT</div>
<button onclick="setDrift('${v.id}','left');renderVoiceConfigList()" style="padding:4px 8px;background:${(seqDrift[v.id] || 'none') === 'left' ? 'rgb(255 0 255 / 60%)' : '#222'};border:1px solid ${(seqDrift[v.id] || 'none') === 'left' ? '#F0F' : '#444'};color:${(seqDrift[v.id] || 'none') === 'left' ? '#000' : '#888'};cursor:pointer;font-size:10px;">◀</button>
<button onclick="setDrift('${v.id}','none');renderVoiceConfigList()" style="padding:4px 8px;background:${(seqDrift[v.id] || 'none') === 'none' ? 'rgb(255 0 255 / 60%)' : '#222'};border:1px solid ${(seqDrift[v.id] || 'none') === 'none' ? '#F0F' : '#444'};color:${(seqDrift[v.id] || 'none') === 'none' ? '#000' : '#888'};cursor:pointer;font-size:8px;">■</button>
<button onclick="setDrift('${v.id}','right');renderVoiceConfigList()" style="padding:4px 8px;background:${(seqDrift[v.id] || 'none') === 'right' ? 'rgb(255 0 255 / 60%)' : '#222'};border:1px solid ${(seqDrift[v.id] || 'none') === 'right' ? '#F0F' : '#444'};color:${(seqDrift[v.id] || 'none') === 'right' ? '#000' : '#888'};cursor:pointer;font-size:10px;">▶</button>
<div style="flex:1;display:flex;align-items:center;gap:4px;">
<span style="color:#888;font-size:7px;">SPD</span>
<input type="range" min="1" max="8" value="${seqDriftSpeed[v.id] || 4}" oninput="seqDriftSpeed['${v.id}']=parseInt(this.value);renderDriftControls()" style="flex:1;height:6px;">
<span style="color:#FFF;font-size:8px;width:12px;">${seqDriftSpeed[v.id] || 4}</span>
</div>
</div>
</div>`;
});
container.innerHTML = html;
}
// Debounce timer for voice preview
let voicePreviewTimer = null;
function updateVoice(id, param, value) {
const v = voiceList.find(x => x.id === id);
if (v) {
v[param] = parseInt(value);
// Sync volume to seqVolume for drift controls
if (param === 'volume') {
seqVolume[id] = parseInt(value);
}
renderVoiceConfigList();
renderDriftControls(); // Refresh drift controls to show updated values
// Sync to breakbeat
if (breakbeat) {
if (!breakbeat.voiceConfig) breakbeat.voiceConfig = {};
breakbeat.voiceConfig[id] = { ...v };
// Update mixStyle for real-time audio changes
if (!breakbeat.mixStyle) breakbeat.mixStyle = {};
const type = v.type;
const pitchMult = Math.pow(2, v.pitch / 12);
const decayMult = v.decay / 50; // 50 is default
if (type === 'kick') {
if (!breakbeat.mixStyle.kick) breakbeat.mixStyle.kick = { startHz: 150, endHz: 50, sweep: 0.05, decay: 0.30, level: 0.80, click: 0.0 };
breakbeat.mixStyle.kick.startHz = 150 * pitchMult;
breakbeat.mixStyle.kick.endHz = 50 * pitchMult;
breakbeat.mixStyle.kick.decay = 0.30 * decayMult;
breakbeat.mixStyle.kick.level = 0.80 * (v.volume / 100);
} else if (type === 'snare') {
if (!breakbeat.mixStyle.snare) breakbeat.mixStyle.snare = { toneHz: 200, toneDecay: 0.10, noiseLevel: 0.40, noiseDecay: 0.15, noiseHz: 3000 };
breakbeat.mixStyle.snare.toneHz = 200 * pitchMult;
breakbeat.mixStyle.snare.noiseHz = 3000 * pitchMult;
breakbeat.mixStyle.snare.noiseDecay = 0.15 * decayMult;
breakbeat.mixStyle.snare.noiseLevel = 0.40 * (v.volume / 100);
} else if (type === 'bass') {
if (!breakbeat.mixStyle.bass) breakbeat.mixStyle.bass = { filterMin: 400, filterMax: 1200, sweepUpProb: 0.20, qMin: 3, qMax: 7, detune: 0.005, sub: 0.50 };
breakbeat.mixStyle.bass.filterMin = 400 * pitchMult;
breakbeat.mixStyle.bass.filterMax = 1200 * pitchMult;
}
}
// Real-time audio preview (debounced)
clearTimeout(voicePreviewTimer);
voicePreviewTimer = setTimeout(() => previewVoice(id, v), 80);
}
}
// Play a preview sound for the voice being edited
function previewVoice(id, voiceConfig) {
initAudio();
const type = voiceConfig.type;
const vol = (voiceConfig.volume / 100) * 0.5; // Preview at 50% of set volume
const pitchMult = Math.pow(2, voiceConfig.pitch / 12);
const now = audioCtx.currentTime;
try {
if (type === 'kick') {
playKick(now, vol * 0.8, pitchMult);
} else if (type === 'snare') {
playSnare(now, vol * 0.7, pitchMult);
} else if (type === 'hihat') {
playHiHat(now, vol * 0.5, pitchMult, null, -1, 'hat0');
} else if (type === 'bass') {
// Play a short bass note
const note = 36 + (voiceConfig.pitch || 0);
playSmartBass(now, note, vol * 0.5, 'bass0');
} else if (type === 'pad' || type === 'lead') {
// Play a short synth note using GB wave or FM
const note = 60 + (voiceConfig.pitch || 0);
if (typeof playGBWave === 'function') {
playGBWave(now, note, vol * 0.4, 0.3);
}
}
} catch (e) {
console.log('Preview error:', e);
}
}
function resetVoiceConfig() {
voiceList = [
{ id: 'kick0', type: 'kick', name: 'KICK0', volume: 100, pan: 0, pitch: 0, decay: 50 },
{ id: 'snare0', type: 'snare', name: 'SNARE0', volume: 100, pan: 0, pitch: 0, decay: 50 },
{ id: 'hihat0', type: 'hihat', name: 'HIHAT0', volume: 100, pan: 0, pitch: 0, decay: 50 },
{ id: 'bass0', type: 'bass', name: 'BASS0', volume: 100, pan: 0, pitch: 0, decay: 50 },
{ id: 'pad0', type: 'pad', name: 'PAD0', volume: 100, pan: 0, pitch: 0, decay: 50 },
{ id: 'lead0', type: 'lead', name: 'LEAD0', volume: 100, pan: 0, pitch: 0, decay: 50 }
];
renderVoiceConfigList();
if (breakbeat) breakbeat.voiceConfig = null;
// Also update main sequencer UI - rebuild drift controls
renderDriftControls();
renderSeqCanvas();
}
function getActiveVoices() {
return voiceList.map(v => v.id);
}
function randomizeSeqPattern() {
if (!seqPatterns) return;
const scale = SCALE_INTERVALS[seqScale] || SCALE_INTERVALS.minor;
const bassNotes = scale.map(i => 36 + i);
const padNotes = scale.map(i => 60 + i);
const leadNotes = scale.map(i => 60 + i);
// Clear user-painted tracking - randomize = fresh start
seqUserPainted = {};
// Randomize all voices in voiceList
voiceList.forEach(v => {
// Ensure pattern exists
if (!seqPatterns[v.id]) {
seqPatterns[v.id] = new Array(16).fill(0);
}
for (let i = 0; i < 16; i++) {
let newVal = 0;
if (v.type === 'kick') {
newVal = (i % 4 === 0 || (i === 10 && Math.random() < 0.5)) ? 0.8 : 0;
} else if (v.type === 'snare' || v.type === 'clap') {
newVal = (i === 4 || i === 12) ? 0.8 : (Math.random() < 0.15 ? 0.6 : 0);
} else if (v.type === 'hihat' || v.type === 'perc' || v.type === 'shaker') {
newVal = Math.random() < 0.6 ? 0.5 + Math.random() * 0.4 : 0;
} else if (v.type === 'cowbell' || v.type === 'rimshot' || v.type === 'pop') {
newVal = Math.random() < 0.2 ? 0.7 : 0;
} else if (v.type === 'tom' || v.type === 'conga') {
newVal = Math.random() < 0.25 ? 0.6 + Math.random() * 0.3 : 0;
} else if (v.type === 'bass' || v.type === 'sub' || v.type === 'acid') {
newVal = Math.random() < 0.35 ? bassNotes[Math.floor(Math.random() * bassNotes.length)] : 0;
} else if (v.type === 'pad' || v.type === 'stab') {
newVal = Math.random() < 0.25 ? padNotes[Math.floor(Math.random() * padNotes.length)] : 0;
} else if (v.type === 'lead' || v.type === 'whammy' || v.type === 'xpoly') {
newVal = Math.random() < 0.3 ? leadNotes[Math.floor(Math.random() * leadNotes.length)] : 0;
}
seqPatterns[v.id][i] = newVal;
// Mark randomized cells as "user" so they show colored (use voiceId not type)
if (newVal > 0) {
seqUserPainted[`${v.id}-${i}`] = true;
}
}
});
// Also randomize ghost patterns in breakbeat.patterns (rimshot, shaker, cowbell, tom, conga)
if (breakbeat && breakbeat.patterns) {
breakbeat.patterns.rimshot = new Array(16).fill(0).map(() => Math.random() < 0.08 ? 1 : 0);
breakbeat.patterns.shaker = new Array(16).fill(0).map((_, i) => (i % 2 === 1 && Math.random() < 0.3) ? 1 : 0);
breakbeat.patterns.cowbell = new Array(16).fill(0).map(() => Math.random() < 0.06 ? 1 : 0);
breakbeat.patterns.tom = new Array(16).fill(0).map((_, i) => (i >= 12 && Math.random() < 0.15) ? 1 : 0);
breakbeat.patterns.conga = new Array(16).fill(0).map(() => Math.random() < 0.1 ? 1 : 0);
// Also randomize core drum ghost patterns
breakbeat.patterns.kick = new Array(16).fill(0).map((_, i) => (i % 4 === 0 || Math.random() < 0.08) ? 1 : 0);
breakbeat.patterns.snare = new Array(16).fill(0).map((_, i) => ((i === 4 || i === 12) || Math.random() < 0.05) ? 1 : 0);
breakbeat.patterns.hihat = new Array(16).fill(0).map(() => Math.random() < 0.85 ? 1 : 0);
breakbeat.patterns.clap = new Array(16).fill(0).map((_, i) => (i === 4 || i === 12) ? 1 : 0);
// Extended voices - initialize all
// Use breakbeat scales (which respect harmony mode) or fallback to function-scope scales
const bassScale = breakbeat.bassScale || bassNotes;
const padScale = breakbeat.padScale || padNotes;
breakbeat.patterns.pop = new Array(16).fill(0).map(() => Math.random() < 0.15 ? 1 : 0);
breakbeat.patterns.acid = new Array(16).fill(0).map(() => Math.random() < 0.35 ? bassScale[Math.floor(Math.random() * bassScale.length)] : 0);
breakbeat.patterns.stab = new Array(16).fill(0).map(() => Math.random() < 0.25 ? padScale[Math.floor(Math.random() * padScale.length)] : 0);
breakbeat.patterns.whammy = new Array(16).fill(0).map(() => Math.random() < 0.22 ? padScale[Math.floor(Math.random() * padScale.length)] : 0);
breakbeat.patterns.xpoly = new Array(16).fill(0).map(() => Math.random() < 0.28 ? padScale[Math.floor(Math.random() * padScale.length)] : 0);
breakbeat.patterns.squarepusher = new Array(16).fill(0).map(() => Math.random() < 0.05 ? bassScale[Math.floor(Math.random() * bassScale.length)] : 0);
breakbeat.patterns.uziq = new Array(16).fill(0).map(() => Math.random() < 0.22 ? padScale[Math.floor(Math.random() * padScale.length)] : 0);
}
}
// Helper function to ensure all voices have independent pattern arrays (no linking)
// Must be at global scope so it can be called from clearSeqPattern()
function ensureVoiceIndependence() {
if (!seqPatterns || !voiceList) return;
voiceList.forEach((v, idx) => {
// Check if this voice is accidentally linked to base type
if (seqPatterns[v.type] && seqPatterns[v.id] === seqPatterns[v.type]) {
// Linked! Break the link by creating independent copy
seqPatterns[v.id] = [...seqPatterns[v.type]]; // Copy, don't link
} else if (!seqPatterns[v.id]) {
// Missing array - create independent one
// For first voice of type, copy from base type; others start empty
if (seqPatterns[v.type] && voiceList.findIndex(x => x.type === v.type) === idx) {
seqPatterns[v.id] = [...seqPatterns[v.type]]; // Copy, don't link
} else {
seqPatterns[v.id] = new Array(16).fill(0);
}
}
});
}
function clearSeqPattern() {
// Cancel any active call/response solo
if (callResponseTimeout) {
clearTimeout(callResponseTimeout);
callResponseTimeout = null;
}
callResponseFadeTimers.forEach(t => clearTimeout(t));
callResponseFadeTimers = [];
callResponseSide = null;
callResponseBackup = {};
callResponseClickCount = 0;
// Reset mute/solo states
if (breakbeat && breakbeat.voiceMute) {
Object.keys(breakbeat.voiceMute).forEach(k => breakbeat.voiceMute[k] = false);
}
if (breakbeat && breakbeat.voiceSolo) {
Object.keys(breakbeat.voiceSolo).forEach(k => breakbeat.voiceSolo[k] = false);
}
Object.keys(seqMute).forEach(k => seqMute[k] = false);
Object.keys(seqSolo).forEach(k => seqSolo[k] = false);
// Clear user-painted tracking
seqUserPainted = {};
// Ensure seqPatterns is synced to breakbeat
if (breakbeat && breakbeat.patterns) {
seqPatterns = breakbeat.patterns;
ensureVoiceIndependence(); // CRITICAL: Decouple all voices for per-voice motifs
}
if (!seqPatterns) return;
// WIPE behavior: Always clear both seqPatterns AND breakbeat.patterns first
// Then: If ghosts are muted, stay silent. Otherwise, reseed from kit.
// Clear seqPatterns
const allKeys = new Set([
...Object.keys(seqPatterns || {}),
...(voiceList || []).map(v => v.id),
...(voiceList || []).map(v => v.type)
]);
allKeys.forEach(key => {
if (seqPatterns && seqPatterns[key] && Array.isArray(seqPatterns[key])) {
seqPatterns[key].fill(0);
}
});
// ALWAYS clear breakbeat.patterns too (prevents fallback in scheduler)
if (breakbeat && breakbeat.patterns) {
Object.keys(breakbeat.patterns).forEach(key => {
if (Array.isArray(breakbeat.patterns[key])) {
breakbeat.patterns[key].fill(0);
}
});
}
// Now decide: reseed or stay silent?
if (seqGhostMute) {
// Ghosts muted = stay silent, don't regenerate
// (Patterns already cleared above)
} else {
// Ghosts on = reset to kit vibe: clear then reseed
// Clear ALL pattern arrays first
const allKeys = new Set([
...Object.keys(seqPatterns || {}),
...(voiceList || []).map(v => v.id),
...(voiceList || []).map(v => v.type)
]);
allKeys.forEach(key => {
if (seqPatterns && seqPatterns[key] && Array.isArray(seqPatterns[key])) {
seqPatterns[key].fill(0);
}
});
// Reseed patterns using the game's kit logic (CLEAR = reset to kit vibe)
if (breakbeat && breakbeat.patterns) {
const prevOverride = breakbeat.kitOverrideName;
const desiredKit = (breakbeat.currentKitName && String(breakbeat.currentKitName).trim())
? String(breakbeat.currentKitName).trim()
: ((seqKitName && String(seqKitName).trim()) ? String(seqKitName).trim() : null);
const prevBpm = breakbeat.bpm;
const hadPendingBpm = Object.prototype.hasOwnProperty.call(breakbeat, 'pendingBpm');
const prevPendingBpm = breakbeat.pendingBpm;
if (desiredKit) breakbeat.kitOverrideName = desiredKit;
randomizePatterns();
breakbeat.kitOverrideName = prevOverride;
// Keep tempo stable on CLEAR (avoid surprise BPM jumps)
if (Number.isFinite(prevBpm)) breakbeat.bpm = prevBpm;
if (hadPendingBpm) {
breakbeat.pendingBpm = prevPendingBpm;
} else {
delete breakbeat.pendingBpm;
}
// Ensure seqPatterns points to live patterns after reseed
seqPatterns = breakbeat.patterns;
// CRITICAL: Decouple all voices immediately after assignment
ensureVoiceIndependence();
// Start gradual "ghost → user" graduation over the next PHRASE bars
startSeqGraduation();
} else {
// Fallback if breakbeat isn't initialized
generateInitialPatterns();
}
}
// Update UI
if (breakbeat && Number.isFinite(breakbeat.bpm)) seqBpm = breakbeat.bpm;
updateMuteSoloState();
renderDriftControls();
updateSeqInfo();
renderSeqCanvas();
logEvolution('CLEAR');
}
function generateInitialPatterns() {
if (!breakbeat || !breakbeat.patterns) return;
const scale = SCALE_INTERVALS[seqScale] || SCALE_INTERVALS.minor;
const root = 36;
const bassNotes = scale.map(i => root + i);
const padNotes = scale.map(i => root + 24 + i);
// Ensure arrays exist
if (!breakbeat.patterns.kick) breakbeat.patterns.kick = new Array(16).fill(0);
if (!breakbeat.patterns.snare) breakbeat.patterns.snare = new Array(16).fill(0);
if (!breakbeat.patterns.hihat) breakbeat.patterns.hihat = new Array(16).fill(0);
if (!breakbeat.patterns.bass) breakbeat.patterns.bass = new Array(16).fill(0);
if (!breakbeat.patterns.pad) breakbeat.patterns.pad = new Array(16).fill(0);
if (!breakbeat.patterns.lead) breakbeat.patterns.lead = new Array(16).fill(0);
// Basic four-on-floor kick
breakbeat.patterns.kick[0] = 1;
breakbeat.patterns.kick[4] = 1;
breakbeat.patterns.kick[8] = 1;
breakbeat.patterns.kick[12] = 1;
// Backbeat snare (2 and 4)
breakbeat.patterns.snare[4] = 1;
breakbeat.patterns.snare[12] = 1;
// Offbeat hi-hats
breakbeat.patterns.hihat[2] = 0.7;
breakbeat.patterns.hihat[6] = 0.7;
breakbeat.patterns.hihat[10] = 0.7;
breakbeat.patterns.hihat[14] = 0.7;
// Bass on 1 and syncopated
breakbeat.patterns.bass[0] = bassNotes[0];
breakbeat.patterns.bass[6] = bassNotes[0];
breakbeat.patterns.bass[10] = bassNotes[2] || bassNotes[0];
// Sparse pad chord hits
breakbeat.patterns.pad[0] = padNotes[0];
breakbeat.patterns.pad[8] = padNotes[2] || padNotes[0];
// Also populate voiceList entries that map to these types
voiceList.forEach(v => {
if (!seqPatterns[v.id]) {
seqPatterns[v.id] = new Array(16).fill(0);
}
// Copy from base type pattern
const basePattern = breakbeat.patterns[v.type];
if (basePattern && Array.isArray(basePattern)) {
for (let i = 0; i < 16; i++) {
seqPatterns[v.id][i] = basePattern[i];
}
}
});
}
function updateBpmSliderRange() {
const slider = document.getElementById('musicBpmSlider');
if (!slider) return;
slider.min = musicSettings.bpmMin;
slider.max = musicSettings.bpmMax;
// Clamp current value to new range
const currentVal = parseInt(slider.value);
if (currentVal < musicSettings.bpmMin) slider.value = musicSettings.bpmMin;
if (currentVal > musicSettings.bpmMax) slider.value = musicSettings.bpmMax;
document.getElementById('musicBpmSliderVal').textContent = slider.value;
}
function setBpmFromSlider() {
const slider = document.getElementById('musicBpmSlider');
if (!slider || !breakbeat) return;
const newBpm = parseInt(slider.value);
document.getElementById('musicBpmSliderVal').textContent = newBpm;
// Queue smooth BPM transition
breakbeat.pendingBpm = newBpm;
logEvolution(`BPM set: ${newBpm}`);
}
// Live UI sync - updates slider/display to reflect actual BPM as it evolves
let liveUpdateInterval = null;
function startLiveUISync() {
if (liveUpdateInterval) return;
liveUpdateInterval = setInterval(() => {
if (!breakbeat || !breakbeat.isPlaying) return;
const currentBpm = Math.round(breakbeat.bpm);
// Update music modal BPM slider
const bpmSlider = document.getElementById('musicBpmSlider');
const bpmVal = document.getElementById('musicBpmSliderVal');
if (bpmSlider && bpmVal) {
if (document.activeElement !== bpmSlider) {
bpmSlider.value = currentBpm;
bpmVal.textContent = currentBpm;
}
}
// Update sequencer BPM input
const seqBpmEl = document.getElementById('seqBpmInput');
if (seqBpmEl && document.activeElement !== seqBpmEl) {
seqBpmEl.value = currentBpm;
}
// Update transport status (PLAYING | xxx BPM)
const statusEl = document.getElementById('seqTransportStatus');
if (statusEl && seqTransportState === 'playing') {
statusEl.textContent = `PLAYING | ${currentBpm} BPM`;
}
// Keep seqBpm in sync
seqBpm = breakbeat.bpm;
}, 250); // Update 4x per second
}
function stopLiveUISync() {
if (liveUpdateInterval) {
clearInterval(liveUpdateInterval);
liveUpdateInterval = null;
}
}
function applyScaleToBreakbeat() {
if (!breakbeat) return;
const intervals = SCALE_INTERVALS[musicSettings.scale] || SCALE_INTERVALS.minor;
const root = 36; // C2 for bass
const padRoot = 60; // C4 for pad/lead
// Build scales from intervals
breakbeat.bassScale = intervals.map(i => root + i);
breakbeat.bassScaleMinor = intervals.map(i => root + i); // Use same scale
breakbeat.padScale = intervals.map(i => padRoot + i);
breakbeat.padScaleMinor = intervals.map(i => padRoot + i); // Use same scale
// Update harmony mode display (now shows scale name instead of major/minor)
if (breakbeat.harmony) {
breakbeat.harmony.mode = musicSettings.scale;
}
}
// Evolution log system
const evolutionLog = [];
let lastLoggedState = {};
// Snapshot system - stores recent pattern states for "rewind"
const patternSnapshots = [];
const MAX_SNAPSHOTS = 10;
function takeSnapshot() {
if (!breakbeat || !breakbeat.patterns) {
logEvolution('SNAPSHOT failed: no patterns');
return;
}
const snapshot = {
bar: breakbeat.barCount || 0,
time: Date.now(),
bpm: breakbeat.bpm,
kitName: breakbeat.currentKitName,
scale: musicSettings.scale,
patterns: {
kick: [...breakbeat.patterns.kick],
snare: [...breakbeat.patterns.snare],
hihat: [...breakbeat.patterns.hihat],
bass: [...breakbeat.patterns.bass],
pad: [...breakbeat.patterns.pad]
}
};
patternSnapshots.push(snapshot);
if (patternSnapshots.length > MAX_SNAPSHOTS) {
patternSnapshots.shift();
}
drawScopeSceneMarkers(); // Update scene markers in perf pad
logEvolution(`SNAPSHOT saved (${patternSnapshots.length}/${MAX_SNAPSHOTS})`);
}
function rewindToSnapshot() {
if (patternSnapshots.length === 0) {
logEvolution('REWIND failed: no snapshots');
return;
}
const snapshot = patternSnapshots.pop();
if (!breakbeat) return;
// Restore patterns
Object.assign(breakbeat.patterns, snapshot.patterns);
if (!seqBpmLocked) breakbeat.bpm = snapshot.bpm;
breakbeat.currentKitName = snapshot.kitName;
// Restore scale if different
if (snapshot.scale && snapshot.scale !== musicSettings.scale) {
musicSettings.scale = snapshot.scale;
document.getElementById('musicScale').value = snapshot.scale;
applyScaleToBreakbeat();
}
// Update BPM slider
const bpmSlider = document.getElementById('musicBpmSlider');
if (bpmSlider) {
bpmSlider.value = Math.round(snapshot.bpm);
document.getElementById('musicBpmSliderVal').textContent = Math.round(snapshot.bpm);
}
logEvolution(`REWIND bar ${snapshot.bar} (${patternSnapshots.length} left)`);
}
// Smart auto-buffering: snapshot before interesting events
// Keeps last 4 bars of "good stuff" based on log activity
let lastAutoSnapshotBar = -4;
function maybeAutoSnapshot() {
if (!breakbeat) return;
const bar = breakbeat.barCount;
// Don't snapshot too frequently (at least 2 bars apart)
if (bar - lastAutoSnapshotBar < 2) return;
// Check recent log for interesting events in last 4 bars
const recentEvents = evolutionLog.filter(e => e.bar >= bar - 4 && e.bar < bar);
const hasInteresting = recentEvents.some(e =>
e.event.includes('MOTIF') ||
e.event.includes('CHORUS') ||
e.event.includes('KIT') ||
e.event.includes('MIX')
);
// Auto-snapshot if: interesting event happened, or every 4 bars as fallback
if (hasInteresting || (bar > 0 && bar % 4 === 0)) {
const snapshot = {
bar: bar,
time: Date.now(),
bpm: breakbeat.bpm,
kitName: breakbeat.currentKitName,
scale: musicSettings.scale,
reason: hasInteresting ? recentEvents.map(e => e.event).join(', ').slice(0, 50) : 'auto',
patterns: {
kick: [...breakbeat.patterns.kick],
snare: [...breakbeat.patterns.snare],
hihat: [...breakbeat.patterns.hihat],
bass: [...breakbeat.patterns.bass],
pad: [...breakbeat.patterns.pad]
}
};
patternSnapshots.push(snapshot);
if (patternSnapshots.length > MAX_SNAPSHOTS) {
patternSnapshots.shift();
}
drawScopeSceneMarkers(); // Update scene markers in perf pad
lastAutoSnapshotBar = bar;
}
}
function logEvolution(event) {
const bar = (breakbeat && breakbeat.barCount) || 0;
const timestamp = `[${String(bar).padStart(3, '0')}]`;
evolutionLog.push({ bar, event, time: Date.now() });
const color = event.includes('BREAKDOWN') ? '#F44' :
event.includes('BUILD') ? '#FF0' :
event.includes('DROP') ? '#0F0' :
event.includes('CHORUS') ? '#F0F' :
event.includes('RALLY') ? '#FF0' :
event.includes('KIT') ? '#0F0' :
event.includes('MOTIF') ? '#0AF' :
event.includes('BPM') ? '#F0F' :
event.includes('SCALE') ? '#0AF' : '#0FF';
// Update music settings modal log
const logEl = document.getElementById('musicEvolutionLog');
if (logEl) {
const line = document.createElement('div');
line.textContent = `${timestamp} ${event}`;
line.style.color = color;
logEl.appendChild(line);
logEl.scrollTop = logEl.scrollHeight;
}
// Also update sequencer modal log
const seqLogEl = document.getElementById('seqEvolutionLog');
if (seqLogEl) {
const line = document.createElement('div');
line.textContent = `${timestamp} ${event}`;
line.style.color = color;
seqLogEl.appendChild(line);
seqLogEl.scrollTop = seqLogEl.scrollHeight;
// Keep only last 10 entries in sequencer log
while (seqLogEl.children.length > 10) {
seqLogEl.removeChild(seqLogEl.firstChild);
}
}
}
function clearEvolutionLog() {
evolutionLog.length = 0;
lastLoggedState = {};
const logEl = document.getElementById('musicEvolutionLog');
if (logEl) logEl.innerHTML = '<div style="color:#666;">Log cleared</div>';
}
function exportSoundtrack() {
const data = {
version: 1,
exportedAt: new Date().toISOString(),
settings: { ...musicSettings },
log: evolutionLog,
patterns: breakbeat ? {
kick: breakbeat.patterns.kick,
snare: breakbeat.patterns.snare,
hihat: breakbeat.patterns.hihat,
bass: breakbeat.patterns.bass,
pad: breakbeat.patterns.pad
} : null,
kitName: breakbeat ? breakbeat.currentKitName : null,
bpm: breakbeat ? breakbeat.bpm : null
};
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `2gether_ost_${Date.now()}.json`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
logEvolution('EXPORT saved');
}
function importSoundtrack(event) {
const file = event.target.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = (e) => {
try {
const data = JSON.parse(e.target.result);
if (data.settings) {
Object.assign(musicSettings, data.settings);
// Sync UI
document.getElementById('musicBpmMin').value = musicSettings.bpmMin;
document.getElementById('musicBpmMax').value = musicSettings.bpmMax;
document.getElementById('musicKitChance').value = Math.round(musicSettings.kitChance * 100);
document.getElementById('musicKitChanceVal').textContent = Math.round(musicSettings.kitChance * 100) + '%';
document.getElementById('musicChorusChance').value = Math.round(musicSettings.chorusChance * 100);
document.getElementById('musicChorusChanceVal').textContent = Math.round(musicSettings.chorusChance * 100) + '%';
document.getElementById('musicRallyThreshold').value = musicSettings.rallyThreshold;
document.getElementById('musicRallyThresholdVal').textContent = musicSettings.rallyThreshold;
document.getElementById('musicMotifBars').value = musicSettings.motifBars;
document.getElementById('musicScale').value = musicSettings.scale;
}
if (data.patterns && breakbeat) {
Object.assign(breakbeat.patterns, data.patterns);
}
if (data.bpm && breakbeat && !seqBpmLocked) {
breakbeat.bpm = data.bpm;
}
applyScaleToBreakbeat();
logEvolution('IMPORT loaded: ' + (data.kitName || 'custom'));
} catch (err) {
logEvolution('IMPORT failed: ' + err.message);
}
};
reader.readAsText(file);
event.target.value = ''; // Reset input
}
function updateMusicStatusDisplay() {
// Render all existing log entries when modal opens
const logEl = document.getElementById('musicEvolutionLog');
if (!logEl) return;
if (evolutionLog.length === 0) {
logEl.innerHTML = '<div style="color:#666;">Waiting for music...</div>';
} else {
// Render all logged events
logEl.innerHTML = '';
evolutionLog.forEach(entry => {
const line = document.createElement('div');
const timestamp = `[${String(entry.bar).padStart(3, '0')}]`;
line.textContent = `${timestamp} ${entry.event}`;
line.style.color = entry.event.includes('BREAKDOWN') ? '#F44' :
entry.event.includes('BUILD') ? '#FF0' :
entry.event.includes('DROP') ? '#0F0' :
entry.event.includes('CHORUS') ? '#F0F' :
entry.event.includes('RALLY') ? '#FF0' :
entry.event.includes('KIT') ? '#0F0' :
entry.event.includes('MOTIF') ? '#0AF' : '#0FF';
logEl.appendChild(line);
});
logEl.scrollTop = logEl.scrollHeight;
}
}
function forceRerollMusic() {
// Clear kit override so reroll is truly random
breakbeat.kitOverrideName = null;
const select = document.getElementById('musicKitSelect');
if (select) {
select.value = ''; // Reset dropdown to (Random)
}
randomizePatterns();
updateMusicStatusDisplay();
}
function getAvailableKits() {
// Return full kit definitions with patterns
// If breakbeat.availableKits exists (populated by randomizePatterns), use that
if (breakbeat && breakbeat.availableKits && breakbeat.availableKits.length > 0) {
return breakbeat.availableKits;
}
// Otherwise build the full kit list with patterns
return [
{
name: 'ARCADE Donkey Kong', bpm: 110,
kick: parsePackPattern('x...x...x...x...'),
snare: parsePackPattern('....x.......x...'),
noise: parsePackPattern('x.x.x.x.x.x.x.x.'),
bass: [43, 0, 0, 43, 0, 0, 43, 0, 41, 0, 0, 41, 0, 0, 41, 0],
lead: [67, 0, 67, 72, 0, 72, 71, 0, 67, 0, 67, 72, 0, 71, 67, 0]
},
{
name: 'ARCADE Street Fighter II (Guile)', bpm: 115,
kick: parsePackPattern('x..x..x.x..x..x.'),
snare: parsePackPattern('....x.......x...'),
noise: parsePackPattern('xxxxxxxxxxxxxxxx'),
bass: [40, 0, 40, 0, 0, 0, 40, 0, 43, 0, 43, 0, 0, 0, 43, 0],
lead: [64, 0, 67, 0, 71, 0, 72, 71, 67, 0, 64, 0, 67, 0, 0, 0]
},
{
name: 'ARCADE Super Mario Bros', bpm: 90,
kick: parsePackPattern('x...x...x...x...'),
snare: parsePackPattern('....x.......x...'),
noise: parsePackPattern('x.x.x.x.x.x.x.x.'),
bass: [48, 0, 36, 48, 0, 0, 48, 0, 43, 0, 0, 43, 0, 0, 43, 0],
lead: [76, 76, 0, 76, 0, 72, 76, 0, 79, 0, 0, 0, 0, 0, 0, 0]
},
{
name: 'ARCADE Sonic Green Hill', bpm: 124,
kick: parsePackPattern('x...x...x...x...'),
snare: parsePackPattern('....x..x....x..x'),
noise: parsePackPattern('x.x.x.x.x.x.x.x.'),
bass: [0, 48, 43, 43, 41, 41, 43, 43, 48, 48, 43, 43, 41, 41, 43, 43],
lead: [76, 74, 72, 71, 72, 74, 76, 79, 76, 74, 72, 71, 72, 74, 76, 0]
},
{
name: 'ARCADE Zelda Main Theme', bpm: 90,
kick: parsePackPattern('x...x...x...x...'),
snare: parsePackPattern('....x.......x...'),
noise: parsePackPattern('x...x...x...x...'),
bass: [0, 40, 0, 40, 0, 0, 40, 0, 45, 0, 0, 45, 0, 0, 45, 0],
lead: [69, 0, 69, 69, 0, 69, 69, 0, 69, 71, 72, 74, 76, 0, 0, 0]
},
{
name: 'ARCADE Mega Man 2 (Wily)', bpm: 90,
kick: parsePackPattern('x...x...x...x...'),
snare: parsePackPattern('....x.......x...'),
noise: parsePackPattern('x.x.x.x.x.x.x.x.'),
bass: [40, 40, 43, 43, 45, 45, 43, 43, 40, 40, 43, 43, 45, 45, 43, 43],
lead: [64, 67, 71, 67, 64, 67, 71, 72, 71, 67, 64, 67, 71, 67, 64, 0]
},
{
name: 'ARCADE Castlevania (Vampire Killer)', bpm: 110,
kick: parsePackPattern('x...x...x...x...'),
snare: parsePackPattern('....x.......x...'),
noise: parsePackPattern('x.x.x.x.x.x.x.x.'),
bass: [45, 45, 45, 45, 40, 40, 40, 40, 45, 45, 45, 45, 40, 40, 40, 40],
lead: [69, 72, 74, 76, 74, 72, 71, 72, 74, 76, 77, 76, 74, 72, 71, 0]
},
{
name: 'ARCADE Galaga', bpm: 90,
kick: parsePackPattern('x.....x...x.....'),
snare: parsePackPattern('....x.......x...'),
noise: parsePackPattern('..x...x...x...x.'),
bass: [38, 38, 38, 38, 41, 41, 41, 41, 38, 38, 38, 38, 41, 41, 41, 41],
lead: [74, 77, 81, 77, 74, 77, 81, 0, 74, 77, 81, 77, 74, 77, 81, 0]
},
{
name: 'ARCADE OutRun (Magical Sound Shower)', bpm: 90,
kick: parsePackPattern('x...x...x...x...'),
snare: parsePackPattern('....x.......x...'),
noise: parsePackPattern('x.x.x.x.x.x.x.x.'),
bass: [39, 39, 39, 39, 44, 44, 44, 44, 39, 39, 39, 39, 44, 44, 44, 44],
lead: [75, 79, 82, 79, 75, 79, 82, 84, 82, 79, 75, 79, 82, 79, 75, 0]
},
{
name: 'ARCADE Metroid (Ending)', bpm: 95,
kick: [1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0],
snare: [0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0],
noise: [1, 0, 1, 0, 1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 1, 0],
bass: [55, 42, 43, 45, 55, 0, 0, 0, 54, 0, 0, 0, 54, 38, 45, 42],
lead: [71, 66, 67, 69, 74, 0, 0, 0, 69, 0, 0, 0, 79, 78, 74, 69]
},
{
name: 'ARCADE Kid Icarus (Overworld)', bpm: 95,
kick: [1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0],
snare: [0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0],
noise: [1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0],
bass: [50, 57, 50, 57, 50, 57, 50, 57, 51, 58, 51, 58, 51, 58, 51, 58],
lead: [81, 0, 0, 81, 78, 0, 0, 0, 79, 0, 0, 0, 82, 0, 81, 79]
},
{
name: 'ARCADE Contra (Base 2)', bpm: 105,
kick: [1, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0],
snare: [0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0],
noise: [1, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1],
bass: [46, 46, 0, 44, 0, 41, 0, 47, 0, 47, 46, 0, 46, 44, 0, 46],
lead: [70, 70, 0, 73, 0, 73, 0, 76, 0, 76, 75, 0, 75, 73, 0, 73]
},
{
name: 'ARCADE Ninja Gaiden (Act 1)', bpm: 150,
kick: parsePackPattern('x...x...x...x...'),
snare: parsePackPattern('....x.......x...'),
noise: parsePackPattern('x.x.x.x.x.x.x.x.'),
bass: [40, 0, 40, 0, 43, 0, 40, 0, 45, 0, 43, 0, 40, 0, 38, 0],
lead: [76, 79, 0, 76, 0, 79, 81, 0, 83, 81, 79, 0, 76, 0, 74, 76]
},
{
name: 'ARCADE River City Ransom (Boss)', bpm: 140,
kick: parsePackPattern('x..x..x.x..x..x.'),
snare: parsePackPattern('....x.......x...'),
noise: parsePackPattern('x.xxx.x.x.xxx.x.'),
bass: [36, 48, 36, 0, 39, 0, 41, 0, 43, 0, 41, 0, 39, 0, 36, 0],
lead: [72, 0, 75, 0, 79, 0, 75, 72, 0, 75, 79, 82, 79, 75, 72, 0]
},
{
name: 'ARCADE River City Ransom (Shop)', bpm: 115,
kick: parsePackPattern('x...x...x...x...'),
snare: parsePackPattern('....x.......x...'),
noise: parsePackPattern('..x...x...x...x.'),
bass: [0, 0, 0, 48, 0, 0, 50, 0, 52, 0, 0, 50, 0, 0, 48, 0],
lead: [72, 74, 76, 0, 79, 0, 76, 74, 72, 0, 74, 76, 79, 76, 74, 0]
},
{
name: 'ARCADE A Boy and His Blob (Title)', bpm: 100,
kick: parsePackPattern('x.......x.......'),
snare: parsePackPattern('....x.......x...'),
noise: parsePackPattern('....x.x.....x.x.'),
bass: [0, 0, 48, 0, 52, 0, 0, 0, 55, 0, 0, 0, 52, 0, 0, 0],
lead: [67, 0, 72, 0, 74, 0, 76, 79, 0, 76, 74, 0, 72, 0, 67, 0]
},
{
name: 'ARCADE Tetris (Type A)', bpm: 140,
kick: parsePackPattern('x...x...x...x...'),
snare: parsePackPattern('....x.......x...'),
noise: parsePackPattern('x.x.x.x.x.x.x.x.'),
bass: [40, 0, 33, 0, 36, 0, 33, 0, 40, 0, 33, 0, 36, 0, 33, 0],
lead: [76, 0, 71, 72, 74, 0, 72, 71, 69, 0, 69, 72, 76, 0, 74, 72]
},
{
name: 'ARCADE Pokemon Battle', bpm: 124,
kick: parsePackPattern('..x.x...x...x..x'),
snare: parsePackPattern('...x..x....x.xx.'),
noise: parsePackPattern('xxxxxxxxxxxxxxxx'),
bass: [0, 38, 38, 38, 38, 38, 38, 0, 41, 41, 41, 41, 43, 43, 43, 0],
lead: [74, 0, 74, 73, 74, 0, 78, 0, 81, 0, 81, 79, 81, 0, 85, 0]
},
{
name: 'ARCADE Doom (E1M1)', bpm: 100,
kick: parsePackPattern('x...x...x...x...'),
snare: parsePackPattern('....x.......x...'),
noise: parsePackPattern('x.x.x.x.x.x.x.x.'),
bass: [36, 36, 48, 36, 36, 46, 36, 36, 44, 36, 36, 41, 0, 40, 0, 0],
lead: [0, 0, 60, 0, 0, 58, 0, 0, 56, 0, 0, 53, 0, 52, 0, 0]
},
{
name: 'SYNTH Blue Monday', bpm: 124,
kick: parsePackPattern('x...x...x...x...'),
snare: parsePackPattern('....x.......x...'),
noise: parsePackPattern('x.x.x.x.x.x.x.x.'),
bass: [38, 38, 0, 38, 0, 0, 38, 0, 38, 38, 0, 38, 0, 0, 38, 0],
lead: [62, 0, 0, 0, 0, 0, 0, 0, 62, 0, 0, 0, 0, 0, 0, 0],
pad: [62, 0, 0, 0, 65, 0, 0, 0, 69, 0, 0, 0, 65, 0, 0, 0]
},
{
name: 'SYNTH Sweet Dreams (Eurythmics)', bpm: 126,
kick: parsePackPattern('x...x...x...x...'),
snare: parsePackPattern('....x.......x...'),
noise: parsePackPattern('xxxxxxxxxxxxxxxx'),
bass: [36, 0, 0, 36, 0, 0, 36, 0, 36, 0, 0, 36, 0, 0, 36, 0],
lead: [60, 0, 60, 0, 67, 0, 65, 0, 60, 0, 60, 0, 67, 0, 65, 0]
},
{
name: 'SYNTH Axel F (Beverly Hills Cop)', bpm: 110,
kick: parsePackPattern('x...x...x...x...'),
snare: parsePackPattern('....x.......x...'),
noise: parsePackPattern('x.x.x.x.x.x.x.x.'),
bass: [41, 0, 0, 41, 0, 0, 43, 0, 41, 0, 0, 41, 0, 0, 43, 0],
lead: [65, 68, 0, 65, 0, 70, 65, 0, 63, 65, 0, 72, 0, 65, 73, 72]
},
{
name: 'SYNTH Love Will Tear Us Apart', bpm: 78,
kick: [1, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0],
snare: [0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0],
noise: [0, 0, 1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0, 1, 0],
bass: [38, 0, 0, 0, 38, 0, 0, 0, 38, 0, 0, 0, 38, 0, 0, 0],
lead: [66, 0, 69, 0, 66, 0, 69, 0, 66, 0, 69, 0, 66, 0, 69, 0],
pad: [50, 0, 0, 54, 0, 0, 57, 0, 0, 0, 0, 0, 47, 0, 0, 50]
},
{
name: 'SYNTH Knight Rider', bpm: 112,
kick: parsePackPattern('x...x...x...x...'),
snare: parsePackPattern('....x.......x...'),
noise: parsePackPattern('xxxxxxxxxxxxxxxx'),
bass: [36, 37, 36, 37, 36, 34, 36, 34, 36, 37, 36, 37, 36, 34, 36, 34],
lead: [65, 0, 0, 0, 0, 0, 0, 0, 65, 0, 0, 65, 0, 0, 65, 68],
pad: [53, 0, 0, 0, 53, 0, 0, 0, 65, 0, 0, 0, 65, 0, 0, 0]
},
{
name: 'SYNTH Popcorn', bpm: 130,
kick: parsePackPattern('x...x...x...x...'),
snare: parsePackPattern('....x.......x...'),
noise: parsePackPattern('..x...x...x...x.'),
bass: [38, 0, 43, 0, 38, 0, 43, 0, 38, 0, 43, 0, 38, 0, 43, 0],
lead: [72, 70, 72, 65, 60, 65, 0, 60, 72, 70, 72, 65, 60, 65, 0, 60]
},
{
name: 'MACHINES TR-808 Boom Bap', bpm: 90,
kick: parsePackPattern('x.....x...x.....'),
snare: parsePackPattern('....x.......x...'),
noise: parsePackPattern('x.x.x.x.x.x.x.x.'),
bass: [36, 0, 0, 48, 0, 0, 0, 0, 36, 0, 0, 0, 0, 0, 0, 0],
lead: [60, 0, 0, 0, 64, 0, 0, 0, 67, 0, 0, 0, 64, 0, 0, 0],
pad: [60, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0] // Minimal - boom bap focus
},
{
name: 'MACHINES TR-909 Four On Floor', bpm: 124,
kick: parsePackPattern('xxx.x...x...x..x'),
snare: parsePackPattern('....x.......x...'),
noise: parsePackPattern('x.x.x.x.x.x.x.x.'),
bass: [36, 48, 36, 0, 36, 0, 36, 0, 36, 0, 0, 0, 36, 0, 39, 0],
lead: [60, 0, 64, 0, 0, 0, 0, 0, 64, 0, 64, 0, 0, 0, 67, 0],
pad: [60, 0, 0, 62, 0, 0, 64, 0, 0, 0, 67, 0, 0, 0, 0, 0] // Arpeggiated - techno vibe
},
{
name: 'MACHINES CR-78 In The Air', bpm: 94,
kick: parsePackPattern('x.......x.......'),
snare: parsePackPattern('....x.......x...'),
noise: parsePackPattern('x...x...x...x...'),
bass: [0, 0, 52,450, 0, 0, 43, 0, 36, 0, 0, 0, 0, 0, 41, 0],
lead: [60, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
pad: [60, 0, 0, 0, 0, 0, 0, 0, 62, 0, 0, 0, 0, 0, 64, 0] // Sparse - Phil Collins vibe
},
{
name: 'MACHINES JUNO-106 Synthwave', bpm: 100,
kick: parsePackPattern('x...x...x...x...'),
snare: parsePackPattern('....x.......x...'),
noise: parsePackPattern('x.x.x.x.x.x.x.x.'),
bass: [48, 36, 36, 48, 0, 0, 0, 0, 48, 0, 0, 0, 0, 0, 0, 0],
lead: [60, 0, 0, 0, 60, 0, 0, 0, 64, 0, 0, 0, 64, 0, 0, 0],
pad: [60, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0] // Minimal - just root
},
{
name: 'MACHINES TB-303 Acid', bpm: 120,
kick: parsePackPattern('x...x...x...x...'),
snare: parsePackPattern('....x.......x...'),
noise: parsePackPattern('xx.xxxxxxxxxxxxx'),
bass: [0, 60, 70, 36, 60, 50, 55, 60, 65, 70, 75, 60, 45, 45, 36, 36],
lead: [65, 0, 0, 66, 0, 0, 60, 0, 0, 65, 64, 0, 0, 0, 0, 64],
pad: [0, 0, 71, 0, 0, 0, 71, 0, 0, 0, 71, 0, 0, 0, 71, 0]
},
{
name: 'MACHINES JP-8000 Trance', bpm: 138,
kick: parsePackPattern('x...x...x...x...'),
snare: parsePackPattern('....x.......x...'),
noise: parsePackPattern('x.x.x.x.x.x.x.x.'),
bass: [0,45,45,45,0,45,45,45,0,45,45,45,0,48,45,48],
lead: [0, 0, 0, 60, 0, 0, 67, 0, 0, 67, 45, 0, 0, 0, 0, 0],
pad: [60, 0, 0, 0, 64, 0, 0, 0, 67, 0, 0, 0, 64, 0, 0, 0]
},
{
name: 'MACHINES MS-20 Aggro', bpm: 125,
kick: parsePackPattern('x...x...x...x...'),
snare: parsePackPattern('....x.......x...'),
noise: parsePackPattern('x.x.x.x.x.x.x.x.'),
bass: [36, 0, 48, 39, 36, 0, 39, 0, 0, 0, 0, 0, 0, 0, 0, 0],
lead: [60, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
pad: [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
},
{
name: 'MACHINES M1 House', bpm: 122,
kick: parsePackPattern('x...x...x...x...'),
snare: parsePackPattern('....x.......x...'),
noise: parsePackPattern('x.x.x.x.x.x.x.x.'),
bass: [48, 0, 0, 0, 0, 0, 0, 36, 48, 0, 0, 0, 0, 0, 0, 36],
lead: [60, 60, 60, 60, 64, 64, 64, 64, 0, 0, 0, 0, 0, 0, 0, 0],
pad: [60, 0, 0, 0, 64, 0, 0, 0, 67, 0, 0, 0, 64, 0, 0, 0]
},
{
name: 'MACHINES Volca Techno', bpm: 120,
kick: parsePackPattern('x...x...x...x...'),
snare: parsePackPattern('....x.......x...'),
noise: parsePackPattern('x.x.x.x.x.x.x.x.'),
bass: [36, 60, 0, 36, 60, 0, 39, 36, 0, 0, 0, 0, 0, 0, 48, 0],
lead: [60, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
pad: [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
},
{
name: 'MACHINES DX7 FM', bpm: 116,
kick: parsePackPattern('x...x...x...x...'),
snare: parsePackPattern('....x.......x...'),
noise: parsePackPattern('x.x.x.x.x.x.x.x.'),
bass: [36, 0, 0, 48, 0, 0, 36, 0, 0, 39, 0, 0, 0, 36, 0, 0],
lead: [72, 0, 0, 0, 0, 0, 0, 0, 76, 0, 0, 0, 0, 0, 0, 0],
pad: [60, 0, 0, 0, 0, 0, 0, 0, 64, 0, 0, 0, 0, 0, 0, 0]
},
{
name: 'MACHINES Minimoog Bass', bpm: 110,
kick: parsePackPattern('x...x...x...x...'),
snare: parsePackPattern('....x.......x...'),
noise: parsePackPattern('x.x.x.x.x.x.x.x.'),
bass: [36, 48, 0, 0, 0, 0, 36, 0, 0, 36, 0, 0, 39, 0, 0, 0],
lead: [60, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
pad: [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
},
{
name: 'MACHINES Prophet-5 Poly', bpm: 90,
kick: parsePackPattern('x...x...x...x...'),
snare: parsePackPattern('....x.......x...'),
noise: parsePackPattern('x.x.x.x.x.x.x.x.'),
bass: [48, 36, 36, 36, 36, 60, 36, 36, 48, 0, 0, 0, 0, 0, 0, 0],
lead: [60, 0, 0, 64, 0, 0, 67, 0, 0, 71, 0, 0, 0, 0, 0, 0],
pad: [60, 0, 0, 0, 64, 0, 0, 0, 67, 0, 0, 0, 71, 0, 0, 0]
},
{
name: 'MACHINES OB-X Jump', bpm: 132,
kick: parsePackPattern('x...x...x...x...'),
snare: parsePackPattern('....x.......x...'),
noise: parsePackPattern('x.x.x.x.x.x.x.x.'),
bass: [0, 36, 0, 0, 0, 52, 0, 0, 48, 0, 0, 36, 0, 0, 0, 0],
lead: [64, 0, 0, 64, 0, 0, 64, 0, 0, 64, 0, 0, 0, 0, 0, 0],
pad: [64, 0, 0, 0, 67, 0, 0, 0, 71, 0, 0, 0, 67, 0, 0, 0]
},
{
name: 'MACHINES Fairlight Orch', bpm: 120,
kick: parsePackPattern('x...x...x...x...'),
snare: parsePackPattern('....x.......x...'),
noise: parsePackPattern('x.x.x.x.x.x.x.x.'),
bass: [0, 52, 0, 0, 48, 45, 0, 0, 48, 0, 0, 52, 0, 0, 36, 0],
lead: [72, 0, 0, 0, 0, 0, 0, 0, 72, 0, 0, 0, 0, 0, 0, 0],
pad: [60, 0, 0, 0, 64, 0, 0, 0, 67, 0, 0, 0, 72, 0, 0, 0]
}
];
}
function populateKitDropdown() {
// 1. Target the Music Settings Modal Dropdown
const musicSelect = document.getElementById('musicKitSelect');
// 2. Target the Sequencer Modal Dropdown
const seqSelect = document.getElementById('seqKitSelect');
// Get the authoritative list of kits
const kits = (breakbeat && breakbeat.availableKits && breakbeat.availableKits.length > 0)
? breakbeat.availableKits
: getAvailableKits();
// Helper to generate options
const generateOptions = (currentValue) => {
let html = '<option value="">(Random)</option>';
kits.forEach(kit => {
if (kit && kit.name) {
// Clean up name for display (optional, removes category prefix for cleaner UI)
const displayName = kit.name.replace(/^(ARCADE|SYNTH|MACHINES) /, '');
const isSelected = kit.name === currentValue ? 'selected' : '';
html += `<option value="${kit.name}" ${isSelected}>${displayName}</option>`;
}
});
return html;
};
// Populate Music Settings Dropdown
if (musicSelect) {
const currentVal = musicSelect.value;
musicSelect.innerHTML = generateOptions(breakbeat.kitOverrideName || currentVal);
}
// Populate Sequencer Dropdown
if (seqSelect) {
// Preserve current selection if it exists, otherwise use state
const currentVal = seqKitName || breakbeat.currentKitName;
seqSelect.innerHTML = generateOptions(currentVal);
}
}
function updateKitSelection() {
const select = document.getElementById('musicKitSelect');
const kitName = select ? select.value : '';
if (kitName) {
breakbeat.kitOverrideName = kitName;
} else {
breakbeat.kitOverrideName = null;
}
// Dim kit chance slider when a specific kit is forced
const kitChanceRow = document.getElementById('musicKitChance').parentElement;
if (kitChanceRow) {
kitChanceRow.style.opacity = kitName ? '0.4' : '1';
}
// Reroll with new kit selection
randomizePatterns();
updateMusicStatusDisplay();
}
function toggleMusicPlayStop() {
const btn = document.getElementById('musicPlayStopBtn');
if (breakbeat && breakbeat.isPlaying) {
stopBreakbeat();
musicEnabled = false;
btn.textContent = '▶ Play';
btn.style.background = '#0F0';
} else {
musicEnabled = true;
randomizePatterns();
startBreakbeat();
btn.textContent = '⏹ Stop';
btn.style.background = '#F44';
}
document.getElementById('musicBtn').style.opacity = musicEnabled ? '1' : '0.5';
updateMusicStatusDisplay();
}
function updatePlayStopButton() {
const btn = document.getElementById('musicPlayStopBtn');
if (!btn) return;
if (breakbeat && breakbeat.isPlaying) {
btn.textContent = '⏹ Stop';
btn.style.background = '#F44';
} else {
btn.textContent = '▶ Play';
btn.style.background = '#0F0';
}
}
function quickToggleMusic() {
// Quick mute/unmute without opening modal (for the button's secondary action)
musicEnabled = !musicEnabled;
document.getElementById('musicBtn').style.opacity = musicEnabled ? '1' : '0.5';
if (musicGain) {
musicGain.gain.value = (musicEnabled && sfxEnabled) ? 1 : 0;
}
if (musicEnabled) {
randomizePatterns();
if (!(breakbeat && breakbeat.isPlaying) && !seqBpmLocked) {
const range = musicSettings.bpmMax - musicSettings.bpmMin;
breakbeat.bpm = musicSettings.bpmMin + Math.random() * range;
}
}
}
function playCollectSound() {
if (!sfxEnabled) return;
const ctx = initAudio();
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.type = 'sine';
osc.frequency.setValueAtTime(523, ctx.currentTime);
osc.frequency.exponentialRampToValueAtTime(1047, ctx.currentTime + 0.1);
gain.gain.setValueAtTime(0.3, ctx.currentTime);
gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 0.2);
osc.connect(gain);
gain.connect(ctx.destination);
osc.start(ctx.currentTime);
osc.stop(ctx.currentTime + 0.2);
}
function playHurtSound() {
if (!sfxEnabled) return;
const ctx = initAudio();
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.type = 'sawtooth';
osc.frequency.setValueAtTime(200, ctx.currentTime);
osc.frequency.exponentialRampToValueAtTime(80, ctx.currentTime + 0.15);
gain.gain.setValueAtTime(0.2, ctx.currentTime);
gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 0.15);
osc.connect(gain);
gain.connect(ctx.destination);
osc.start(ctx.currentTime);
osc.stop(ctx.currentTime + 0.15);
}
function playLoveWarningSound() {
if (!sfxEnabled) return;
const ctx = initAudio();
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.type = 'triangle';
osc.frequency.setValueAtTime(150, ctx.currentTime);
gain.gain.setValueAtTime(0.1, ctx.currentTime);
gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 0.05);
osc.connect(gain);
gain.connect(ctx.destination);
osc.start(ctx.currentTime);
osc.stop(ctx.currentTime + 0.05);
}
function midiToFreq(midi) {
return 440 * Math.pow(2, (midi - 69) / 12);
}
function playCloseIntervalChime() {
if (!sfxEnabled) return;
const ctx = initAudio();
const rootMidi = 60; // C4
const interval = Math.random() < 0.5 ? 7 : 12; // fifth or octave
const f1 = midiToFreq(rootMidi);
const f2 = midiToFreq(rootMidi + interval);
const o1 = ctx.createOscillator();
const o2 = ctx.createOscillator();
const filter = ctx.createBiquadFilter();
const g = ctx.createGain();
o1.type = 'triangle';
o2.type = 'triangle';
o1.frequency.setValueAtTime(f1, ctx.currentTime);
o2.frequency.setValueAtTime(f2, ctx.currentTime);
// Presence so it cuts through (soft, but audible)
filter.type = 'bandpass';
filter.frequency.setValueAtTime(1800, ctx.currentTime);
filter.Q.setValueAtTime(3.5, ctx.currentTime);
// Very subtle pluck-like envelope
g.gain.setValueAtTime(0.0001, ctx.currentTime);
g.gain.exponentialRampToValueAtTime(0.05, ctx.currentTime + 0.01);
g.gain.exponentialRampToValueAtTime(0.0001, ctx.currentTime + 0.18);
o1.connect(filter);
o2.connect(filter);
filter.connect(g);
// Route to destination so it's audible even if music is muted
g.connect(ctx.destination);
// Also record if recording is active
if (recordingDest) g.connect(recordingDest);
o1.start(ctx.currentTime);
o2.start(ctx.currentTime);
o1.stop(ctx.currentTime + 0.2);
o2.stop(ctx.currentTime + 0.2);
}
function playWinSound() {
if (!sfxEnabled) return;
const ctx = initAudio();
const notes = [523, 659, 784, 1047];
notes.forEach((freq, i) => {
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.type = 'sine';
osc.frequency.value = freq;
gain.gain.setValueAtTime(0.2, ctx.currentTime + i * 0.15);
gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + i * 0.15 + 0.3);
osc.connect(gain);
gain.connect(ctx.destination);
osc.start(ctx.currentTime + i * 0.15);
osc.stop(ctx.currentTime + i * 0.15 + 0.3);
});
}
function playTreasureJingle() {
if (!sfxEnabled) return;
const ctx = initAudio();
const now = ctx.currentTime;
// Zelda-ish: quick bright major arpeggio + a tiny sparkle
const base = 659; // E5-ish
const freqs = [base, base * 1.25, base * 1.5, base * 2];
freqs.forEach((f, i) => {
const t = now + i * 0.11;
const o1 = ctx.createOscillator();
const o2 = ctx.createOscillator();
const g = ctx.createGain();
const filter = ctx.createBiquadFilter();
o1.type = 'triangle';
o2.type = 'square';
o1.frequency.setValueAtTime(f, t);
o2.frequency.setValueAtTime(f * 1.003, t);
filter.type = 'bandpass';
filter.frequency.setValueAtTime(Math.min(9000, Math.max(1200, f * 2.2)), t);
filter.Q.setValueAtTime(2.2, t);
g.gain.setValueAtTime(0.0001, t);
g.gain.exponentialRampToValueAtTime(0.12, t + 0.01);
g.gain.exponentialRampToValueAtTime(0.0001, t + 0.18);
o1.connect(filter);
o2.connect(filter);
filter.connect(g);
g.connect(ctx.destination);
o1.start(t);
o2.start(t);
o1.stop(t + 0.22);
o2.stop(t + 0.22);
});
const sparkle = ctx.createOscillator();
const sg = ctx.createGain();
sparkle.type = 'triangle';
sparkle.frequency.setValueAtTime(2400, now + 0.08);
sparkle.frequency.exponentialRampToValueAtTime(5200, now + 0.24);
sg.gain.setValueAtTime(0.0001, now + 0.08);
sg.gain.exponentialRampToValueAtTime(0.08, now + 0.10);
sg.gain.exponentialRampToValueAtTime(0.0001, now + 0.34);
sparkle.connect(sg);
sg.connect(ctx.destination);
sparkle.start(now + 0.08);
sparkle.stop(now + 0.38);
}
function playGameOverSound() {
if (!sfxEnabled) return;
const ctx = initAudio();
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.type = 'sawtooth';
osc.frequency.setValueAtTime(300, ctx.currentTime);
osc.frequency.exponentialRampToValueAtTime(50, ctx.currentTime + 0.5);
gain.gain.setValueAtTime(0.3, ctx.currentTime);
gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 0.5);
osc.connect(gain);
gain.connect(ctx.destination);
osc.start(ctx.currentTime);
osc.stop(ctx.currentTime + 0.5);
}
// === DIRECTOR ($1010 DSL ENGINE) ===
// A simple scripting language for arranging sequences and automating parameters
class Director {
constructor(breakbeat) {
this.breakbeat = breakbeat;
this.running = false;
this.script = "";
this.events = [];
this.loopStart = -1;
this.loopEnd = -1;
this.directorStep = 0;
}
parse(scriptText) {
this.script = scriptText;
this.events = [];
const lines = scriptText.split('\n');
let currentStep = 0;
lines.forEach(line => {
line = line.trim();
if (!line || line.startsWith('#') || line.startsWith('//')) return;
// Time directives
if (line.startsWith('@')) {
const val = line.substring(1).trim();
// Support bars (e.g. @ 4b) or steps (@ 64)
if (val.toLowerCase().endsWith('b')) {
currentStep = parseInt(val) * 16;
} else {
currentStep = parseInt(val);
}
return;
}
if (line.startsWith('>')) {
// Incremental time (> 4b)
const val = line.substring(1).trim();
if (val.toLowerCase().endsWith('b')) {
currentStep += parseInt(val) * 16;
} else {
currentStep += parseInt(val);
}
return;
}
// Commands
const parts = line.split(' ');
const cmd = parts[0].toUpperCase();
if (cmd === 'BPM') {
this.events.push({ step: currentStep, type: 'BPM', value: parseFloat(parts[1]) });
} else if (cmd === 'PATTERN') {
// PATTERN BASS x...x...
this.events.push({ step: currentStep, type: 'PATTERN', target: parts[1].toLowerCase(), value: parts.slice(2).join(' ') });
} else if (cmd === 'VOICE') {
// VOICE MOTIF fmBrass
this.events.push({ step: currentStep, type: 'VOICE', target: parts[1].toLowerCase(), value: parts[2] });
} else if (cmd === 'LAYER') {
// LAYER XWAVE A drift
this.events.push({ step: currentStep, type: 'LAYER', target: parts[1].toUpperCase(), index: parts[2], theme: parts[3] });
} else if (cmd === 'CHAOS') {
// CHAOS 0.5
this.events.push({ step: currentStep, type: 'CHAOS', value: parseFloat(parts[1]) });
} else if (cmd === 'STOP') {
this.events.push({ step: currentStep, type: 'STOP' });
} else if (cmd === 'LOOP') {
// LOOP START / LOOP END
this.events.push({ step: currentStep, type: 'LOOP', action: parts[1].toUpperCase() });
}
});
//Sort events by step
this.events.sort((a, b) => a.step - b.step);
console.log("Director Compiled:", this.events);
}
start() {
this.running = true;
this.directorStep = 0;
this.loopStart = -1;
this.loopEnd = -1;
const statusEl = document.getElementById('directorStatus');
if (statusEl) {
statusEl.textContent = "RUNNING...";
statusEl.style.color = "#0f0";
}
if (!this.breakbeat.isPlaying) this.breakbeat.toggle();
}
stop() {
this.running = false;
const statusEl = document.getElementById('directorStatus');
if (statusEl) {
statusEl.textContent = "STOPPED";
statusEl.style.color = "#aaa";
}
}
tick(step) {
if (!this.running) return;
// Handle looping
if (this.loopEnd !== -1 && step >= this.loopEnd) {
if (this.loopStart !== -1) {
this.directorStep = this.loopStart;
if (this.loopEnd > this.loopStart) return;
}
}
// Find events for this step
const nowEvents = this.events.filter(e => e.step === step);
nowEvents.forEach(e => this.execute(e));
}
execute(e) {
console.log(`Director [${e.step}]:`, e);
const statusEl = document.getElementById('directorStatus');
if (statusEl) {
statusEl.textContent = `STEP ${e.step}: ${e.type}`;
}
if (e.type === 'BPM') {
this.breakbeat.bpm = e.value;
const bpmEl = document.getElementById('bpmValue');
if (bpmEl) bpmEl.textContent = e.value;
} else if (e.type === 'PATTERN') {
// Simple pattern parser: x=on, .=off
if (this.breakbeat.sequences && this.breakbeat.sequences[e.target]) {
const pattern = e.value.replace(/\s/g, '');
for (let i = 0; i < 16; i++) {
const char = pattern[i % pattern.length];
this.breakbeat.sequences[e.target][i] = (char === 'x' || char === 'X' || char === '1');
}
}
} else if (e.type === 'VOICE') {
if (e.target === 'motif' || e.target === 'lead') {
this.breakbeat.motifVoice = e.value;
const voiceEl = document.getElementById('seqMotifVoice');
if (voiceEl) voiceEl.value = e.value;
}
} else if (e.type === 'LAYER') {
// XWAVE layer changes
if (e.target === 'XWAVE' && this.breakbeat.xwaveSlots) {
const idx = e.index.toUpperCase().charCodeAt(0) - 65; // A=0, B=1, etc
if (idx >= 0 && idx < this.breakbeat.xwaveSlots.length && e.theme) {
this.breakbeat.xwaveSlots[idx].theme = e.theme;
}
}
} else if (e.type === 'CHAOS') {
this.breakbeat.chaosLevel = Math.max(0, Math.min(1, e.value));
const chaosEl = document.getElementById('chaosSlider');
if (chaosEl) chaosEl.value = this.breakbeat.chaosLevel;
} else if (e.type === 'STOP') {
if (this.breakbeat.isPlaying) this.breakbeat.toggle();
this.stop();
} else if (e.type === 'LOOP') {
if (e.action === 'START') this.loopStart = e.step;
if (e.action === 'END') this.loopEnd = e.step;
}
}
}
// === BREAKBEAT SEQUENCER ===
const breakbeat = {
bpm: 82,
steps: 16,
currentStep: 0,
isPlaying: false,
timerID: null,
step: 0,
barCount: 0,
barHits: { bass: 0, pad: 0 },
prevBarHits: { bass: 0, pad: 0 },
barStepHits: { bass: Array(16).fill(0) },
prevBarStepHits: { bass: Array(16).fill(0) },
barOnCount: 0,
prevBarOnCount: 0,
noodlyBars: 0,
rallyBarsRemaining: 0,
kitOverrideName: null,
lastCdpStep: -999, // Track last CDP artifact step for cooldown
availableKits: [],
// Kit rotation tracking - prevent staleness
kitRotation: {
barsOnCurrentKit: 0,
recentKits: [], // Last 3 kits used (avoid repeats)
maxRecentKits: 3,
isRandom: true // Track if currently in random mode vs kit mode
},
// Gradual kit transition (1-4 bars)
kitTransition: {
active: false,
barsRemaining: 0,
voiceGroups: [
['kick', 'snare'], // Bar 1: drums
['hihat', 'clap', 'noise', 'pop'], // Bar 2: percussion
['bass', 'acid'], // Bar 3: bass/low-end
['lead', 'pad', 'stab', 'whammy', 'xpoly', 'squarepusher', 'uziq'] // Bar 4: melodic
],
currentGroup: 0
},
// DJ-style smooth BPM transition system
bpmTransition: {
active: false,
startBpm: 0,
targetBpm: 0,
barsTotal: 4, // ramp over 4 bars by default
barsRemaining: 0
},
evolve: {
chaos: 3,
dns: 0,
lockRoot: true,
target: { bass: 0.38, pad: 0.22 },
cooldownBarsRemaining: 0
},
harmony: {
mode: 'major',
lockBarsRemaining: 0
},
xsid: {
leadPresetIndex: 0,
bassPresetIndex: 0,
lockBarsRemaining: 0
},
motif: {
notes: Array(16).fill(0),
voice: 'xsidLead',
barsRemaining: 0,
isCadence: false
},
// DJ Breakdown/Build system (club transitions)
breakdown: {
active: false,
phase: 'none', // 'none' | 'breakdown' | 'build' | 'drop'
barsRemaining: 0,
barsTotal: 0,
breakdownBars: 0,
buildBars: 0,
strippedVoices: {}, // Track which voices were stripped and their original patterns
stripProgress: {} // Track progressive stripping per voice
},
barInstinct: {
barSkip: {},
barDiv: {},
barPhase: {},
pruneMult: {},
downbeatDropAll: false,
downbeatDrop: {},
chorusOn: false,
chorusLockBarsRemaining: 0,
backbeatOn: false,
backbeatLockBarsRemaining: 0,
bassStepOffset: 0,
bassOffsetLockBarsRemaining: 0,
lockBarsRemaining: 0
},
mixStyleName: 'DEFAULT',
mixStyle: {
kick: { startHz: 150, endHz: 50, sweep: 0.05, decay: 0.30, level: 0.80, click: 0.0 },
snare: { toneHz: 200, toneDecay: 0.10, noiseLevel: 0.40, noiseDecay: 0.15, noiseHz: 3000 },
bass: { filterMin: 400, filterMax: 1200, sweepUpProb: 0.20, qMin: 3, qMax: 7, detune: 0.005, sub: 0.50 }
},
patterns: {
kick: [1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0],
snare: [0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0],
hihat: [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1],
clap: [0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0],
// Ghost voices - quieter accents
rimshot: [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
shaker: [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
cowbell: [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
tom: [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
conga: [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
// SID bass - notes (MIDI) or 0 for rest
bass: [36, 0, 0, 0, 0, 0, 36, 0, 0, 0, 0, 0, 36, 0, 0, 0],
// Bass ratchets - 1=single, 2-8=rapid fire hits per step
bassRatch: [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1],
// FM pad - sparse ambient stabs (MIDI notes or 0)
pad: [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
// Lead voice - melodic motifs (MIDI notes or 0)
lead: [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
// Ride cymbal - constant driving pulse (ghosts)
ride: [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
// Extended voices - melodic and percussion
acid: [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], // TB-303 Acid Bass (MIDI notes)
pop: [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], // Drum Pop (1/0)
stab: [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], // FM Brass Stab (MIDI notes)
whammy: [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], // FM Pitch Whammy (MIDI notes)
xpoly: [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], // XPoly Lead (MIDI notes)
squarepusher: [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], // Squarepusher Bass (MIDI notes)
uziq: [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0] // µ-Ziq Melody (MIDI notes)
},
// Bass scale notes (pentatonic in C for chill vibes)
bassScale: [36, 38, 40, 43, 45, 48, 50, 52], // C2, D2, E2, G2, A2, C3, D3, E3
bassScaleMinor: [36, 39, 41, 43, 46, 48, 51, 53],
// Pad scale (higher octave pentatonic)
padScale: [60, 62, 64, 67, 69, 72, 74, 76], // C4, D4, E4, G4, A4, C5, D5, E5
padScaleMinor: [60, 63, 65, 67, 70, 72, 75, 77],
// Lead synth type: 'fm' or 'chip' (randomized per session)
leadType: 'fm'
};
const BAR_VOICES = [
'kick', 'snare', 'hihat', 'clap',
'rimshot', 'shaker', 'cowbell', 'tom', 'conga', 'ride',
'bass', 'pad', 'xpoly', 'xsidLead', 'xsidBass', 'fmWhammy', 'fmBrass'
];
function driftBpmTowardTarget() {
// DJ-style BPM drift: gradually move toward target, 1-3 BPM per phrase
// Like Qbert/Skratch Piklz - beatmatch then nudge the pitch
if (!breakbeat || !breakbeat.isPlaying || seqBpmLocked) return;
const target = breakbeat.targetBpm;
const current = breakbeat.bpm;
if (!Number.isFinite(target) || !Number.isFinite(current)) return;
const diff = target - current;
if (Math.abs(diff) < 0.5) {
// Close enough, snap to target
breakbeat.bpm = target;
return;
}
// Drift 1-2 BPM per bar, direction toward target
// Every 2 bars for more natural phrasing
if (breakbeat.barCount % 2 !== 0) return;
const maxDrift = 1.5 + Math.random(); // 1.5-2.5 BPM per 2 bars
const drift = Math.sign(diff) * Math.min(Math.abs(diff), maxDrift);
breakbeat.bpm = current + drift;
// Log if significant drift
if (Math.abs(drift) >= 1) {
logEvolution(`BPM drift: ${Math.round(current)}${Math.round(breakbeat.bpm)} (target ${Math.round(target)})`);
}
}
// === USER INTERACTION TRACKING (DISABLED - chapters always advance) ===
function markUserEditing() {
// DISABLED - no longer blocking anything, just a no-op
// userIsEditing = true;
// if (userEditTimeout) clearTimeout(userEditTimeout);
// userEditTimeout = setTimeout(() => {
// userIsEditing = false;
// userEditTimeout = null;
// }, USER_EDIT_IDLE_TIME);
}
function isUserEditing() {
return false; // Always return false - never block
}
function rollBarInstincts(force = false) {
// Called at bar boundary. This is a "musical intention" layer that runs
// before per-step probabilities.
// === BREAKDOWN/BUILD PHASE PROGRESSION ===
if (breakbeat.breakdown && breakbeat.breakdown.active && !force && !breakbeat.previewMode) {
breakbeat.breakdown.barsRemaining--;
// === PROGRESSIVE RESTORATION DURING BUILD ===
// Each bar during build, restore some steps back
if (breakbeat.breakdown.phase === 'build' && breakbeat.breakdown.strippedVoices) {
const voicesToRestore = Object.keys(breakbeat.breakdown.strippedVoices);
if (voicesToRestore.length > 0) {
const progress = 1.0 - (breakbeat.breakdown.barsRemaining / breakbeat.breakdown.buildBars);
const restoreAmount = 0.3 + (progress * 0.2); // 0.3-0.5 per bar (more as build progresses)
restoreBreakdownStripping(voicesToRestore, restoreAmount);
}
}
if (breakbeat.breakdown.barsRemaining <= 0) {
// Advance to next phase
if (breakbeat.breakdown.phase === 'breakdown') {
// Breakdown → Build
breakbeat.breakdown.phase = 'build';
breakbeat.breakdown.barsRemaining = breakbeat.breakdown.buildBars;
logEvolution(`BUILD START (${breakbeat.breakdown.buildBars} bars)`);
console.log(`[BREAKDOWN] Build phase starting at bar ${breakbeat.barCount} - ${breakbeat.breakdown.buildBars} bars`);
startBuildPhase();
} else if (breakbeat.breakdown.phase === 'build') {
// Build → Drop
breakbeat.breakdown.phase = 'drop';
breakbeat.breakdown.barsRemaining = 1;
logEvolution(`DROP!`);
console.log(`[BREAKDOWN] DROP! at bar ${breakbeat.barCount}`);
triggerDrop();
} else if (breakbeat.breakdown.phase === 'drop') {
// Drop → None (reset, trigger kit change)
breakbeat.breakdown.active = false;
breakbeat.breakdown.phase = 'none';
breakbeat.breakdown.barsRemaining = 0;
// Reset kit rotation counter and trigger normal kit change
if (breakbeat.kitRotation) {
breakbeat.kitRotation.barsOnCurrentKit = 0;
}
// Force kit change on next check
logEvolution(`BREAKDOWN COMPLETE → KIT CHANGE`);
console.log(`[BREAKDOWN] Complete at bar ${breakbeat.barCount} - triggering kit change`);
}
} else {
// Update FX automation during active phase
updateBreakdownFX();
}
}
// DJ-style BPM drift toward target
driftBpmTowardTarget();
const quantizeNoteToScale = (note, scale) => {
if (!note || !Array.isArray(scale) || scale.length === 0) return note;
let best = scale[0];
let bestD = Infinity;
for (let i = 0; i < scale.length; i++) {
const base = scale[i];
for (const sh of [-12, 0, 12]) {
const cand = base + sh;
const d = Math.abs(cand - note);
if (d < bestD) {
bestD = d;
best = cand;
}
}
}
return best;
};
// Phrase locking: with 25% chance on every 4th bar, lock bar-level gating
// (skip/div/phase) for 4/8/12/16 bars so the generator has "chapters".
// Downbeat drops still roll every bar (keeps it lively).
let locked = false;
if (!force) {
if (breakbeat.barInstinct.lockBarsRemaining > 0) {
breakbeat.barInstinct.lockBarsRemaining--;
locked = true;
} else if (breakbeat.barCount % 4 === 0 && Math.random() < 0.25) {
const lengths = [4, 8, 12, 16];
const lockLen = lengths[Math.floor(Math.random() * lengths.length)];
breakbeat.barInstinct.lockBarsRemaining = lockLen - 1;
}
}
// 5% chance: drop step-0 downbeat for *all* voices this bar
breakbeat.barInstinct.downbeatDropAll = Math.random() < 0.05;
if (!force) {
if (breakbeat.harmony.lockBarsRemaining > 0) {
breakbeat.harmony.lockBarsRemaining--;
} else {
const prevMode = breakbeat.harmony.mode;
// Musical scale transitions (not random!)
// Only change if user hasn't manually set a scale
const userSetScale = musicSettings && musicSettings.scale && musicSettings.scale !== 'minor';
if (userSetScale) {
// User has set scale - respect it, no auto-switching
breakbeat.harmony.mode = musicSettings.scale;
} else {
// Auto-harmony: Use musical relationships for smooth transitions
if (breakbeat.barCount % 4 === 0 && Math.random() < 0.20) {
// Musical scale transition options
const transitionType = Math.random();
if (transitionType < 0.50) {
// 50%: Relative major/minor (C major ↔ A minor) - smoothest transition
breakbeat.harmony.mode = (prevMode === 'minor') ? 'major' : 'minor';
logEvolution(`HARMONY → ${breakbeat.harmony.mode.toUpperCase()} (relative)`);
} else if (transitionType < 0.75) {
// 25%: Parallel major/minor (C major ↔ C minor) - same root, different mood
// For now, just toggle major/minor (parallel would need root tracking)
breakbeat.harmony.mode = (prevMode === 'minor') ? 'major' : 'minor';
logEvolution(`HARMONY → ${breakbeat.harmony.mode.toUpperCase()} (parallel)`);
} else {
// 25%: Modal shift (dorian, mixolydian, pentatonic) - adds variety
const modalScales = ['dorian', 'mixolydian', 'pentatonic'];
breakbeat.harmony.mode = modalScales[Math.floor(Math.random() * modalScales.length)];
logEvolution(`HARMONY → ${breakbeat.harmony.mode.toUpperCase()} (modal)`);
}
const lengths = [4, 8, 12];
const lockLen = lengths[Math.floor(Math.random() * lengths.length)];
breakbeat.harmony.lockBarsRemaining = lockLen - 1;
} else {
// Default to major if no transition
breakbeat.harmony.mode = prevMode || 'major';
}
}
// Keep bass notes consonant with the new harmony mode.
// Also update extended melodic voices (acid, sub use bass scale; stab, whammy, xpoly use pad scale)
if (prevMode !== breakbeat.harmony.mode) {
// Generate scales based on current harmony mode (supports all scales, not just major/minor)
const root = 36; // C2
const padRoot = 60; // C4
const intervals = SCALE_INTERVALS[breakbeat.harmony.mode] || SCALE_INTERVALS.major;
const bassScale = intervals.map(i => root + i);
const padScale = intervals.map(i => padRoot + i);
// Bass voices (low-end melodic)
['bass', 'acid', 'squarepusher'].forEach(voiceType => {
const pat = breakbeat.patterns && breakbeat.patterns[voiceType];
if (pat && Array.isArray(pat)) {
for (let i = 0; i < 16; i++) {
if (pat[i]) pat[i] = quantizeNoteToScale(pat[i], bassScale);
}
}
});
// Melodic voices (higher register)
['stab', 'whammy', 'xpoly', 'uziq'].forEach(voiceType => {
const pat = breakbeat.patterns && breakbeat.patterns[voiceType];
if (pat && Array.isArray(pat)) {
for (let i = 0; i < 16; i++) {
if (pat[i]) pat[i] = quantizeNoteToScale(pat[i], padScale);
}
}
});
}
}
}
// Chorus mode: 15% chance on every 4th bar to lock an "all-in" feel for 4/8 bars.
if (!force) {
if (breakbeat.barInstinct.chorusLockBarsRemaining > 0) {
breakbeat.barInstinct.chorusLockBarsRemaining--;
breakbeat.barInstinct.chorusOn = true;
} else {
breakbeat.barInstinct.chorusOn = false;
if (breakbeat.barCount % 4 === 0 && Math.random() < musicSettings.chorusChance) {
const lengths = [4, 8];
const lockLen = lengths[Math.floor(Math.random() * lengths.length)];
breakbeat.barInstinct.chorusOn = true;
breakbeat.barInstinct.chorusLockBarsRemaining = lockLen - 1;
logEvolution(`CHORUS ON (${lockLen} bars)`);
}
}
}
// Phrase-lock XSID accent presets so they feel intentional instead of sparkly-random
// Also track staleness to force change after 16-24 bars on same preset
if (!breakbeat.xsid) {
breakbeat.xsid = { leadPresetIndex: 0, bassPresetIndex: 0, lockBarsRemaining: 0, samePresetBars: 0, maxSameBars: 16 + Math.floor(Math.random() * 9) };
}
if (!force) {
breakbeat.xsid.samePresetBars = (breakbeat.xsid.samePresetBars || 0) + 1;
const forceChange = breakbeat.xsid.samePresetBars >= (breakbeat.xsid.maxSameBars || 20);
if (breakbeat.xsid.lockBarsRemaining > 0 && !forceChange) {
breakbeat.xsid.lockBarsRemaining--;
} else if (forceChange || breakbeat.barCount % 4 === 0) {
const oldLead = breakbeat.xsid.leadPresetIndex;
const oldBass = breakbeat.xsid.bassPresetIndex;
// Pick new presets, avoiding same ones if forcing change
let newLead = Math.floor(Math.random() * XSID_LEAD_PRESETS.length);
let newBass = Math.floor(Math.random() * XSID_BASS_PRESETS.length);
if (forceChange) {
while (newLead === oldLead && XSID_LEAD_PRESETS.length > 1) {
newLead = Math.floor(Math.random() * XSID_LEAD_PRESETS.length);
}
logEvolution(`XSID FORCE CHANGE after ${breakbeat.xsid.samePresetBars} bars`);
}
breakbeat.xsid.leadPresetIndex = newLead;
breakbeat.xsid.bassPresetIndex = newBass;
const lengths = [4, 8];
const lockLen = lengths[Math.floor(Math.random() * lengths.length)];
breakbeat.xsid.lockBarsRemaining = lockLen - 1;
// Reset staleness, pick new max range (16-24 bars)
breakbeat.xsid.samePresetBars = 0;
breakbeat.xsid.maxSameBars = 16 + Math.floor(Math.random() * 9);
}
}
if (!force) {
if (breakbeat.barInstinct.backbeatLockBarsRemaining > 0) {
breakbeat.barInstinct.backbeatLockBarsRemaining--;
breakbeat.barInstinct.backbeatOn = true;
} else {
breakbeat.barInstinct.backbeatOn = false;
if (breakbeat.barCount % 4 === 0 && Math.random() < 0.12) {
breakbeat.barInstinct.backbeatOn = true;
const lengths = [4, 8];
const lockLen = lengths[Math.floor(Math.random() * lengths.length)];
breakbeat.barInstinct.backbeatLockBarsRemaining = lockLen - 1;
}
}
}
if (!force) {
if (breakbeat.barInstinct.bassOffsetLockBarsRemaining > 0) {
breakbeat.barInstinct.bassOffsetLockBarsRemaining--;
} else {
breakbeat.barInstinct.bassStepOffset = 0;
if (breakbeat.barCount % 4 === 0 && Math.random() < 0.18) {
// Prefer slight late/shifted entries (avoid "on the 1" most of the time)
const offsets = [2, 2, 3, 1, 5, 0];
breakbeat.barInstinct.bassStepOffset = offsets[Math.floor(Math.random() * offsets.length)];
const lengths = [4, 8, 12];
const lockLen = lengths[Math.floor(Math.random() * lengths.length)];
breakbeat.barInstinct.bassOffsetLockBarsRemaining = lockLen - 1;
}
}
}
BAR_VOICES.forEach(v => {
breakbeat.barInstinct.pruneMult[v] = 1;
let pruneChance = 0.12;
let minMult = 0.70;
let maxMult = 0.92;
if (v === 'kick') {
pruneChance = 0.05;
minMult = 0.85;
maxMult = 0.98;
} else if (v === 'snare') {
pruneChance = 0.11;
minMult = 0.72;
maxMult = 0.94;
} else if (v === 'hihat') {
pruneChance = 0.18;
minMult = 0.62;
maxMult = 0.88;
} else if (v === 'clap') {
pruneChance = 0.14;
minMult = 0.68;
maxMult = 0.92;
} else if (v === 'rimshot' || v === 'shaker' || v === 'cowbell' || v === 'tom' || v === 'conga') {
pruneChance = 0.18;
minMult = 0.60;
maxMult = 0.86;
} else if (v === 'bass') {
pruneChance = 0.08;
minMult = 0.78;
maxMult = 0.96;
} else if (v === 'pad' || v === 'xpoly' || v === 'xsidLead' || v === 'xsidBass') {
pruneChance = 0.12;
minMult = 0.70;
maxMult = 0.92;
}
if (Math.random() < pruneChance) {
breakbeat.barInstinct.pruneMult[v] = minMult + Math.random() * (maxMult - minMult);
}
// Downbeat drops re-roll every bar
breakbeat.barInstinct.downbeatDrop[v] = false;
if (!breakbeat.barInstinct.downbeatDropAll && Math.random() < 0.05) {
breakbeat.barInstinct.downbeatDrop[v] = true;
}
// Chorus: reduce bar-level gating so voices "play along".
if (breakbeat.barInstinct.chorusOn) {
breakbeat.barInstinct.barSkip[v] = false;
breakbeat.barInstinct.barDiv[v] = 1;
breakbeat.barInstinct.barPhase[v] = 0;
breakbeat.barInstinct.pruneMult[v] = 1;
return;
}
// If we're in a locked phrase, keep the bar-level gating as-is
if (locked) return;
// Reset defaults when not locked
breakbeat.barInstinct.barSkip[v] = false;
breakbeat.barInstinct.barDiv[v] = 1;
breakbeat.barInstinct.barPhase[v] = 0;
// 5-10% chance: skip this entire bar for that voice
if (Math.random() < 0.07) {
breakbeat.barInstinct.barSkip[v] = true;
return;
}
// 5-10% chance: play only every other bar (phase randomized)
if (Math.random() < 0.07) {
breakbeat.barInstinct.barDiv[v] = 2;
breakbeat.barInstinct.barPhase[v] = (Math.random() < 0.5) ? 0 : 1;
return;
}
// ~5% chance: only play every 3rd or 4th bar (phase randomized)
if (Math.random() < 0.05) {
const div = (Math.random() < 0.65) ? 4 : 3;
breakbeat.barInstinct.barDiv[v] = div;
breakbeat.barInstinct.barPhase[v] = Math.floor(Math.random() * div);
}
});
// GB Wave shape evolution: organ 51%, others 7% each
// Track staleness to force change after 12-20 bars on same shape
if (!breakbeat.xwave) {
breakbeat.xwave = { shape: 'organ', lockBarsRemaining: 0, sameShapeBars: 0, maxSameBars: 12 + Math.floor(Math.random() * 9) };
}
if (!force) {
breakbeat.xwave.sameShapeBars = (breakbeat.xwave.sameShapeBars || 0) + 1;
const forceWaveChange = breakbeat.xwave.sameShapeBars >= (breakbeat.xwave.maxSameBars || 16);
if (breakbeat.xwave.lockBarsRemaining > 0 && !forceWaveChange) {
breakbeat.xwave.lockBarsRemaining--;
} else if (forceWaveChange || (breakbeat.barCount % 4 === 0 && Math.random() < 0.20)) {
const oldShape = breakbeat.xwave.shape;
const r = Math.random();
let newShape;
if (r < 0.51) {
newShape = 'organ'; // 51%
} else if (r < 0.58) {
newShape = 'sine'; // 7%
} else if (r < 0.65) {
newShape = 'saw'; // 7%
} else if (r < 0.72) {
newShape = 'square'; // 7%
} else if (r < 0.79) {
newShape = 'tri'; // 7%
} else if (r < 0.86) {
newShape = 'pulse25'; // 7%
} else if (r < 0.93) {
newShape = 'bass'; // 7%
} else {
newShape = 'noise'; // 7%
}
// If forcing change, ensure we pick something different
if (forceWaveChange && newShape === oldShape) {
const shapes = ['organ', 'sine', 'saw', 'square', 'tri', 'pulse25', 'bass', 'noise'];
const others = shapes.filter(s => s !== oldShape);
newShape = others[Math.floor(Math.random() * others.length)];
logEvolution(`WAVE FORCE CHANGE after ${breakbeat.xwave.sameShapeBars} bars`);
}
if (newShape !== oldShape) {
breakbeat.xwave.shape = newShape;
xwaveShape = newShape;
xwaveCustom = null; // Clear custom, use preset
const lengths = [4, 8, 12];
const lockLen = lengths[Math.floor(Math.random() * lengths.length)];
breakbeat.xwave.lockBarsRemaining = lockLen - 1;
breakbeat.xwave.sameShapeBars = 0;
breakbeat.xwave.maxSameBars = 12 + Math.floor(Math.random() * 9);
logEvolution(`WAVE → ${newShape.toUpperCase()}`);
}
}
}
// Kit rotation: escalating chance to change, forced at staleMax bars, no repeats
// Prevents both "stuck on random forever" and "stuck on same kit forever"
// If staleMax is -1, kit is LOCKED and never auto-changes
if (!breakbeat.kitRotation) {
breakbeat.kitRotation = { barsOnCurrentKit: 0, recentKits: [], maxRecentKits: 3, isRandom: !breakbeat.currentKitName };
}
const staleMax = musicSettings.kitStaleMax;
// Skip kit rotation entirely if LOCKED (-1)
if (staleMax === -1) {
// Kit is locked, don't rotate
} else if (!force) {
const kr = breakbeat.kitRotation;
kr.barsOnCurrentKit = (kr.barsOnCurrentKit || 0) + 1;
// Only check every 2 bars for natural phrasing
if (breakbeat.barCount % 2 === 0) {
const bars = kr.barsOnCurrentKit;
const effectiveStaleMax = staleMax || 12;
// Escalating change probability relative to effectiveStaleMax
// Scales so 50% chance around 2/3 mark, forced at effectiveStaleMax
let changeProb = 0;
if (bars >= effectiveStaleMax) {
changeProb = 1.0; // Forced
} else if (bars >= effectiveStaleMax * 0.83) {
changeProb = 0.70;
} else if (bars >= effectiveStaleMax * 0.67) {
changeProb = 0.50;
} else if (bars >= effectiveStaleMax * 0.50) {
changeProb = 0.30;
} else if (bars >= effectiveStaleMax * 0.33) {
changeProb = 0.15;
} else if (bars >= 2) {
changeProb = 0.05;
}
if (Math.random() < changeProb) {
// Check if we should trigger breakdown instead of immediate kit change
// Only when approaching forced change (75%+ of staleMax) and in game mode
const approachingForced = bars >= effectiveStaleMax * 0.75;
const breakdownChance = (musicSettings && musicSettings.breakdownChance) ? musicSettings.breakdownChance : 0.20;
const shouldBreakdown = approachingForced &&
!breakbeat.previewMode &&
!breakbeat.breakdown.active &&
Math.random() < breakdownChance;
if (shouldBreakdown) {
// Trigger breakdown sequence instead of immediate kit change
const minBars = (musicSettings && musicSettings.breakdownBarsMin) ? musicSettings.breakdownBarsMin : 8;
const maxBars = (musicSettings && musicSettings.breakdownBarsMax) ? musicSettings.breakdownBarsMax : 12;
const totalBars = minBars + Math.floor(Math.random() * (maxBars - minBars + 1));
const breakdownBars = 4 + Math.floor(Math.random() * 3); // 4-6 bars
const buildBars = 2 + Math.floor(Math.random() * 3); // 2-4 bars
breakbeat.breakdown.active = true;
breakbeat.breakdown.phase = 'breakdown';
breakbeat.breakdown.barsRemaining = breakdownBars;
breakbeat.breakdown.barsTotal = totalBars;
breakbeat.breakdown.breakdownBars = breakdownBars;
breakbeat.breakdown.buildBars = buildBars;
logEvolution(`BREAKDOWN START (${breakdownBars} bars breakdown, ${buildBars} bars build)`);
console.log(`[BREAKDOWN] Triggered at bar ${breakbeat.barCount} - ${breakdownBars} bars breakdown, ${buildBars} bars build`);
startBreakdownPhase();
return; // Don't change kit yet, wait for drop
}
const kits = breakbeat.availableKits || [];
const wasRandom = kr.isRandom;
const currentKit = breakbeat.currentKitName;
// Toggle between random and kit mode, or pick new kit
// 40% chance to go random if on a kit, 60% chance to pick a kit if random
const goRandom = wasRandom ? (Math.random() < 0.35) : (Math.random() < 0.40);
if (goRandom) {
// Switch to random mode
breakbeat.kitOverrideName = null;
breakbeat.currentKitName = null;
kr.isRandom = true;
kr.barsOnCurrentKit = 0;
// Update dropdown to show "(Random)"
seqKitName = '';
const kitSelect = document.getElementById('seqKitSelect');
if (kitSelect) kitSelect.value = '';
logEvolution(`KIT → RANDOM (after ${bars} bars)`);
// Start gradual transition instead of instant change
startGradualKitTransition();
} else if (kits.length > 0) {
// Pick a new kit, avoiding recent ones
let candidates = kits.filter(k => k && k.name && !kr.recentKits.includes(k.name));
if (candidates.length === 0) candidates = kits; // Fallback if all are recent
const newKit = candidates[Math.floor(Math.random() * candidates.length)];
if (newKit && newKit.name !== currentKit) {
breakbeat.kitOverrideName = newKit.name;
breakbeat.currentKitName = newKit.name;
kr.isRandom = false;
kr.barsOnCurrentKit = 0;
// Track recent kits (keep last 3)
kr.recentKits.push(newKit.name);
if (kr.recentKits.length > kr.maxRecentKits) {
kr.recentKits.shift();
}
// Update dropdown to match the new kit
seqKitName = newKit.name;
const kitSelect = document.getElementById('seqKitSelect');
if (kitSelect) kitSelect.value = newKit.name;
logEvolution(`KIT → ${newKit.name.split(' ')[0]} (after ${bars} bars)`);
// Start gradual transition instead of instant change
startGradualKitTransition();
}
}
}
}
}
// Process gradual kit transition (if active)
if (breakbeat.kitTransition && breakbeat.kitTransition.active) {
advanceKitTransition();
}
}
function startGradualKitTransition() {
// Initialize gradual transition over configurable bars (jam band style)
const kt = breakbeat.kitTransition;
const minBars = musicSettings.transitionBarsMin || 4;
const maxBars = musicSettings.transitionBarsMax || 6;
kt.active = true;
kt.barsRemaining = minBars + Math.floor(Math.random() * (maxBars - minBars + 1));
kt.currentGroup = 0;
logEvolution(` (transition over ${kt.barsRemaining} bars)`);
// Regenerate first voice group immediately (drums)
advanceKitTransition();
}
function advanceKitTransition() {
const kt = breakbeat.kitTransition;
if (!kt || !kt.active) return;
// Continue any pending step-by-step morphs from previous bars
continuePendingMorphs();
const groups = kt.voiceGroups;
const groupIndex = kt.currentGroup;
if (groupIndex >= groups.length) {
// Transition complete
kt.active = false;
kt.currentGroup = 0;
kt.pendingMorphs = {};
logEvolution(`KIT transition complete`);
return;
}
let voices = groups[groupIndex];
// Filter extended voices by prevalence settings
voices = voices.filter(voice => {
if (voice === 'acid') return Math.random() < musicSettings.acidPrevalence;
if (voice === 'pop') return Math.random() < musicSettings.popPrevalence;
if (voice === 'stab') return Math.random() < musicSettings.stabPrevalence;
if (voice === 'whammy') return Math.random() < musicSettings.whammyPrevalence;
if (voice === 'xpoly') return Math.random() < musicSettings.xpolyPrevalence;
if (voice === 'squarepusher') return Math.random() < musicSettings.squarepusherPrevalence;
if (voice === 'uziq') return Math.random() < musicSettings.uziqPrevalence;
// Core voices always included
return true;
});
// Regenerate patterns for this voice group only
// Some will replace fully, some will morph step-by-step
if (voices.length > 0) {
regenerateVoicesOnly(voices);
}
kt.currentGroup++;
kt.barsRemaining--;
if (kt.barsRemaining <= 0 || kt.currentGroup >= groups.length) {
kt.active = false;
kt.currentGroup = 0;
}
}
// === BREAKDOWN/BUILD DJ SYSTEM ===
// === INTELLIGENT VOICE ANALYSIS (for breakdown stripping) ===
// Analyzes which voices are most important to the groove
function analyzeVoiceImportance() {
const voices = ['kick', 'snare', 'hihat', 'bass', 'pad', 'lead', 'clap', 'acid', 'stab', 'whammy', 'xpoly'];
const importance = {};
// Helper: calculate pattern density
const patternDensity = (arr) => {
if (!arr || !Array.isArray(arr)) return 0;
let on = 0;
for (let i = 0; i < 16; i++) if (arr[i] && arr[i] !== 0) on++;
return on / 16;
};
voices.forEach(voice => {
const pattern = breakbeat.patterns[voice];
if (!pattern) {
importance[voice] = 0;
return;
}
// 1. Pattern density (how many steps are active)
const density = patternDensity(pattern);
// 2. Recent activity (how much it's been playing)
const recentHits = (breakbeat.prevBarHits && breakbeat.prevBarHits[voice])
? breakbeat.prevBarHits[voice] / 16
: 0;
// 3. Musical role weight (foundational voices get base importance)
const roleWeight = {
'kick': 1.2, // Foundation - usually important
'snare': 1.1, // Foundation - usually important
'bass': 1.15, // Groove anchor
'hihat': 0.9, // Texture - less critical
'pad': 0.8, // Atmosphere - can strip
'lead': 0.85, // Melody - can strip
'clap': 0.7, // Accent - can strip
'acid': 0.75, // Extended - can strip
'stab': 0.7, // Extended - can strip
'whammy': 0.65, // Extended - can strip
'xpoly': 0.7 // Extended - can strip
};
// Combined importance score
const baseScore = (density * 0.6) + (recentHits * 0.4);
importance[voice] = baseScore * (roleWeight[voice] || 0.7);
});
return importance;
}
// === SMART VOICE STRIPPING (band-like decisions) ===
// Decides which voices to strip based on importance, sometimes counter-intuitive
function selectVoicesToStrip(importance) {
const voices = Object.keys(importance);
// Sort by importance (most important first)
const sorted = voices
.filter(v => importance[v] > 0) // Only voices that exist
.sort((a, b) => importance[b] - importance[a]);
if (sorted.length === 0) return [];
// Decision: 70% strip less important, 30% strip important (counter-intuitive)
const stripLessImportant = Math.random() < 0.7;
let voicesToStrip = [];
if (stripLessImportant) {
// Expected: Strip less important voices (bottom 40-60%)
const stripCount = Math.floor(sorted.length * (0.4 + Math.random() * 0.2));
voicesToStrip = sorted.slice(-stripCount); // Take from end (least important)
} else {
// Counter-intuitive: Strip important voices (top 20-40%)
// Creates tension - "wait, where did the kick go?!"
const stripCount = Math.max(1, Math.floor(sorted.length * (0.2 + Math.random() * 0.2)));
voicesToStrip = sorted.slice(0, stripCount); // Take from start (most important)
}
// Always keep at least kick OR snare (never strip both foundational voices)
const hasKick = voicesToStrip.includes('kick');
const hasSnare = voicesToStrip.includes('snare');
if (hasKick && hasSnare) {
// Randomly keep one
if (Math.random() < 0.5) {
voicesToStrip = voicesToStrip.filter(v => v !== 'snare');
} else {
voicesToStrip = voicesToStrip.filter(v => v !== 'kick');
}
}
return voicesToStrip;
}
// === APPLY PATTERN STRIPPING ===
// Actually removes steps from patterns (not just probability)
function applyBreakdownStripping(voicesToStrip, stripAmount) {
// stripAmount: 0-1, how much to strip (0.6 = remove 60% of steps)
if (!breakbeat.breakdown.strippedVoices) {
breakbeat.breakdown.strippedVoices = {};
}
voicesToStrip.forEach(voice => {
const pattern = breakbeat.patterns[voice];
if (!pattern || !Array.isArray(pattern)) return;
// Save original pattern if not already saved
if (!breakbeat.breakdown.strippedVoices[voice]) {
breakbeat.breakdown.strippedVoices[voice] = [...pattern];
}
// Calculate how many steps to keep
const activeSteps = pattern.filter(s => s && s !== 0).length;
const stepsToKeep = Math.max(1, Math.floor(activeSteps * (1 - stripAmount)));
// Get list of active step indices
const activeIndices = [];
for (let i = 0; i < 16; i++) {
if (pattern[i] && pattern[i] !== 0) {
activeIndices.push(i);
}
}
// Shuffle and keep only the first N steps
// Preserve root on step 0 for melodic voices
const rootIndex = activeIndices.indexOf(0);
const otherIndices = activeIndices.filter(i => i !== 0);
// Shuffle other indices
for (let i = otherIndices.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[otherIndices[i], otherIndices[j]] = [otherIndices[j], otherIndices[i]];
}
// Build new pattern: keep root if exists, then keep N-1 other steps
const newPattern = Array(16).fill(0);
let kept = 0;
if (rootIndex >= 0 && kept < stepsToKeep) {
newPattern[0] = pattern[0];
kept++;
}
for (let i = 0; i < otherIndices.length && kept < stepsToKeep; i++) {
newPattern[otherIndices[i]] = pattern[otherIndices[i]];
kept++;
}
// Apply stripped pattern
breakbeat.patterns[voice] = newPattern;
});
}
// === RESTORE STRIPPED PATTERNS ===
function restoreBreakdownStripping(voicesToRestore, restoreAmount) {
// restoreAmount: 0-1, how much to restore (0.3 = restore 30% more steps)
if (!breakbeat.breakdown.strippedVoices) return;
voicesToRestore.forEach(voice => {
const original = breakbeat.breakdown.strippedVoices[voice];
if (!original || !Array.isArray(original)) return;
const current = breakbeat.patterns[voice] || Array(16).fill(0);
const originalActive = original.filter(s => s && s !== 0).length;
const currentActive = current.filter(s => s && s !== 0).length;
const targetActive = Math.min(originalActive, currentActive + Math.floor(originalActive * restoreAmount));
// Get missing steps from original
const missingIndices = [];
for (let i = 0; i < 16; i++) {
if (original[i] && original[i] !== 0 && (!current[i] || current[i] === 0)) {
missingIndices.push(i);
}
}
// Shuffle and restore some
for (let i = missingIndices.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[missingIndices[i], missingIndices[j]] = [missingIndices[j], missingIndices[i]];
}
const toRestore = missingIndices.slice(0, targetActive - currentActive);
toRestore.forEach(idx => {
current[idx] = original[idx];
});
breakbeat.patterns[voice] = current;
});
}
function startBreakdownPhase() {
if (!window.breakdownFX || !breakbeat.breakdown) return;
const fx = window.breakdownFX;
const intensity = (musicSettings && musicSettings.breakdownIntensity) ? musicSettings.breakdownIntensity : 1.0;
const now = audioCtx.currentTime;
const breakdownBars = breakbeat.breakdown.breakdownBars;
const barDuration = (60 / breakbeat.bpm) * 4; // 4 beats per bar
const breakdownDuration = breakdownBars * barDuration;
// === INTELLIGENT VOICE STRIPPING ===
// Analyze groove and decide what to strip (like a band would)
const importance = analyzeVoiceImportance();
const voicesToStrip = selectVoicesToStrip(importance);
if (voicesToStrip.length > 0) {
// Strip 60-80% of steps from selected voices
const stripAmount = 0.6 + (Math.random() * 0.2); // 0.6-0.8
applyBreakdownStripping(voicesToStrip, stripAmount);
logEvolution(`BREAKDOWN: Stripped ${voicesToStrip.join(', ')} (${Math.round(stripAmount * 100)}%)`);
}
// Echo riser: Sync to 1/8-note, ramp wet from 0.3 to 0.8
const beatTime = 60 / breakbeat.bpm;
const eighthNote = beatTime * 0.125;
fx.echoDelay.delayTime.setValueAtTime(eighthNote, now);
fx.echoFeedback.gain.setValueAtTime(0.2 * intensity, now);
fx.echoFeedback.gain.linearRampToValueAtTime(0.6 * intensity, now + breakdownDuration);
fx.echoWet.gain.setValueAtTime(0.3 * intensity, now);
fx.echoWet.gain.linearRampToValueAtTime(0.8 * intensity, now + breakdownDuration);
fx.echoDry.gain.setValueAtTime(1.0 - (0.3 * intensity), now);
fx.echoDry.gain.linearRampToValueAtTime(1.0 - (0.8 * intensity), now + breakdownDuration);
// Isolator: Low band sweep from -60dB to +6dB, Mid/High reduce to -12dB
fx.isolatorLow.gain.setValueAtTime(-60, now);
fx.isolatorLow.gain.linearRampToValueAtTime(6 * intensity, now + breakdownDuration);
fx.isolatorMid.gain.setValueAtTime(0, now);
fx.isolatorMid.gain.linearRampToValueAtTime(-12 * intensity, now + breakdownDuration);
fx.isolatorHigh.gain.setValueAtTime(0, now);
fx.isolatorHigh.gain.linearRampToValueAtTime(-12 * intensity, now + breakdownDuration);
// Gate: Light stutter (every 4th step = 1/4-note denial)
// Will be updated per-step in scheduleNote()
}
function startBuildPhase() {
if (!window.breakdownFX || !breakbeat.breakdown) return;
const fx = window.breakdownFX;
const intensity = (musicSettings && musicSettings.breakdownIntensity) ? musicSettings.breakdownIntensity : 1.0;
const now = audioCtx.currentTime;
const buildBars = breakbeat.breakdown.buildBars;
const barDuration = (60 / breakbeat.bpm) * 4;
const buildDuration = buildBars * barDuration;
// Isolator: Continue low at +6dB, sweep mid/high up
fx.isolatorLow.gain.setValueAtTime(6 * intensity, now);
fx.isolatorLow.gain.setValueAtTime(6 * intensity, now + buildDuration); // Maintain
fx.isolatorMid.gain.setValueAtTime(-12 * intensity, now);
fx.isolatorMid.gain.linearRampToValueAtTime(3 * intensity, now + buildDuration);
fx.isolatorHigh.gain.setValueAtTime(-12 * intensity, now);
fx.isolatorHigh.gain.linearRampToValueAtTime(6 * intensity, now + buildDuration);
// Echo: Maintain high wet, reduce feedback slightly
fx.echoFeedback.gain.setValueAtTime(0.6 * intensity, now);
fx.echoFeedback.gain.linearRampToValueAtTime(0.4 * intensity, now + buildDuration);
// Gate: Will be updated per-step for 1/16-note stutter
// Transformer scratches will be triggered per-bar in scheduleNote()
// === PROGRESSIVE RESTORATION ===
// Gradually restore stripped patterns during build
if (breakbeat.breakdown.strippedVoices) {
const voicesToRestore = Object.keys(breakbeat.breakdown.strippedVoices);
if (voicesToRestore.length > 0) {
// Restore 30-50% per bar during build (will be called each bar)
// This happens in phase progression logic
logEvolution(`BUILD: Restoring ${voicesToRestore.join(', ')}`);
}
}
}
function triggerDrop() {
if (!window.breakdownFX || !breakbeat.breakdown) return;
const fx = window.breakdownFX;
const now = audioCtx.currentTime;
// Full restore: All FX to neutral
fx.isolatorLow.gain.setValueAtTime(0, now);
fx.isolatorMid.gain.setValueAtTime(0, now);
fx.isolatorHigh.gain.setValueAtTime(0, now);
fx.echoWet.gain.setValueAtTime(0.2, now);
fx.echoDry.gain.setValueAtTime(0.8, now);
fx.echoFeedback.gain.setValueAtTime(0.2, now);
fx.gate.gain.setValueAtTime(1.0, now); // Full open
// === FULL PATTERN RESTORATION ===
// Restore all stripped patterns completely
if (breakbeat.breakdown.strippedVoices) {
const voicesToRestore = Object.keys(breakbeat.breakdown.strippedVoices);
voicesToRestore.forEach(voice => {
const original = breakbeat.breakdown.strippedVoices[voice];
if (original && Array.isArray(original)) {
breakbeat.patterns[voice] = [...original];
}
});
// Clear stripped voices tracking
breakbeat.breakdown.strippedVoices = {};
if (voicesToRestore.length > 0) {
logEvolution(`DROP: Full restore of ${voicesToRestore.join(', ')}`);
}
}
// Trigger kit change on next bar (after drop completes)
// This happens in phase progression when drop → none
}
function updateBreakdownFX() {
// Called each bar during breakdown/build to update FX automation
// Most automation is handled by ramps in startBreakdownPhase/startBuildPhase
// This can be used for per-bar updates if needed
if (!breakbeat.breakdown || !breakbeat.breakdown.active) return;
}
// Execute transformer scratches during build phase
function executeTransformerScratches(barStartTime, barCount) {
if (!breakbeat.breakdown || breakbeat.breakdown.phase !== 'build') return;
const intensity = (musicSettings && musicSettings.breakdownIntensity) ? musicSettings.breakdownIntensity : 1.0;
const beatTime = 60 / breakbeat.bpm;
const barDuration = beatTime * 4;
for (let bar = 0; bar < barCount; bar++) {
const barTime = barStartTime + (bar * barDuration);
// Ra-ta chirp pattern: 4x per bar (on beats 1, 2, 3, 4)
for (let i = 0; i < 4; i++) {
const chirpTime = barTime + (i * beatTime);
const vol = 0.3 + (Math.random() * 0.2); // 0.3-0.5
playTransformerScratch(chirpTime, vol * intensity);
}
}
}
function regenerateVoicesOnly(voices) {
// Get current kit (or random if none)
const kits = breakbeat.availableKits || [];
const overrideName = breakbeat.kitOverrideName;
const kit = overrideName
? kits.find(k => k && k.name === overrideName)
: (Math.random() < musicSettings.kitChance ? kits[Math.floor(Math.random() * kits.length)] : null);
if (kit) {
breakbeat.currentKitName = kit.name;
}
// Regenerate only the specified voices
// Mix it up: some voices replace all at once, some morph step-by-step
const morphedVoices = [];
const replacedVoices = [];
voices.forEach(voice => {
// Rhythm section (drums + bass) almost always step-morph like a real band
// Other voices use configurable morph chance
const isRhythmSection = ['kick', 'snare', 'hihat', 'bass', 'noise', 'acid', 'pop', 'squarepusher'].includes(voice);
const morphProb = isRhythmSection ? 0.85 : (musicSettings.morphChance || 0.40);
const doMorph = Math.random() < morphProb;
// Get target pattern
let targetPattern = null;
if (voice === 'kick') {
targetPattern = (kit && kit.kick) ? [...kit.kick] : generateRandomPattern(0.42, 16);
} else if (voice === 'snare') {
targetPattern = (kit && kit.snare) ? [...kit.snare] : generateRandomPattern(0.28, 16);
} else if (voice === 'hihat') {
targetPattern = generateRandomPattern(0.55, 16);
} else if (voice === 'clap') {
targetPattern = generateRandomPattern(0.18, 16);
} else if (voice === 'noise') {
targetPattern = (kit && kit.noise) ? [...kit.noise] : generateRandomPattern(0.35, 16);
} else if (voice === 'bass') {
if (kit && kit.bass) {
targetPattern = [...kit.bass];
} else {
const scale = (breakbeat.harmony && breakbeat.harmony.mode === 'minor')
? breakbeat.padScaleMinor : breakbeat.padScale;
targetPattern = generateMelodicPattern(scale.slice(0, 4), 0.38, 16);
}
} else if (voice === 'lead') {
if (kit && kit.lead) {
targetPattern = [...kit.lead];
} else {
const scale = (breakbeat.harmony && breakbeat.harmony.mode === 'minor')
? breakbeat.padScaleMinor : breakbeat.padScale;
targetPattern = generateMelodicPattern(scale, 0.32, 16);
}
} else if (voice === 'pad') {
const scale = (breakbeat.harmony && breakbeat.harmony.mode === 'minor')
? breakbeat.padScaleMinor : breakbeat.padScale;
targetPattern = generateMelodicPattern(scale, 0.22, 16);
} else if (voice === 'pop') {
// Drum Pop - percussion accent
targetPattern = generateRandomPattern(0.15, 16);
} else if (voice === 'acid') {
// TB-303 Acid Bass - bass-like melodic
if (kit && kit.acid) {
targetPattern = [...kit.acid];
} else {
const scale = (breakbeat.harmony && breakbeat.harmony.mode === 'minor')
? breakbeat.bassScaleMinor : breakbeat.bassScale;
targetPattern = generateMelodicPattern(scale, 0.35, 16);
}
} else if (voice === 'stab') {
// FM Brass Stab - melodic accent
if (kit && kit.stab) {
targetPattern = [...kit.stab];
} else {
const scale = (breakbeat.harmony && breakbeat.harmony.mode === 'minor')
? breakbeat.padScaleMinor : breakbeat.padScale;
targetPattern = generateMelodicPattern(scale, 0.25, 16);
}
} else if (voice === 'whammy') {
// FM Pitch Whammy - melodic
if (kit && kit.whammy) {
targetPattern = [...kit.whammy];
} else {
const scale = (breakbeat.harmony && breakbeat.harmony.mode === 'minor')
? breakbeat.padScaleMinor : breakbeat.padScale;
targetPattern = generateMelodicPattern(scale, 0.22, 16);
}
} else if (voice === 'xpoly') {
// XPoly Lead - melodic
if (kit && kit.xpoly) {
targetPattern = [...kit.xpoly];
} else {
const scale = (breakbeat.harmony && breakbeat.harmony.mode === 'minor')
? breakbeat.padScaleMinor : breakbeat.padScale;
targetPattern = generateMelodicPattern(scale, 0.28, 16);
}
} else if (voice === 'squarepusher') {
// Squarepusher Bass - bass-like melodic (uses bassScale)
if (kit && kit.squarepusher) {
targetPattern = [...kit.squarepusher];
} else {
const scale = (breakbeat.harmony && breakbeat.harmony.mode === 'minor')
? breakbeat.bassScaleMinor : breakbeat.bassScale;
targetPattern = generateMelodicPattern(scale, 0.30, 16); // 30% density
}
} else if (voice === 'uziq') {
// µ-Ziq Melody - granular + arp (uses padScale, higher register)
if (kit && kit.uziq) {
targetPattern = [...kit.uziq];
} else {
const scale = (breakbeat.harmony && breakbeat.harmony.mode === 'minor')
? breakbeat.padScaleMinor : breakbeat.padScale;
targetPattern = generateMelodicPattern(scale, 0.22, 16); // 22% density (sparse)
}
}
if (!targetPattern || !breakbeat.patterns[voice]) return;
if (doMorph) {
// Step-by-step morph: replace 4-8 random steps
const stepsToMorph = 4 + Math.floor(Math.random() * 5);
const indices = [];
while (indices.length < stepsToMorph) {
const idx = Math.floor(Math.random() * 16);
if (!indices.includes(idx)) indices.push(idx);
}
indices.forEach(i => {
breakbeat.patterns[voice][i] = targetPattern[i];
});
morphedVoices.push(voice);
// Queue remaining steps for next bar if transition still active
if (!breakbeat.kitTransition.pendingMorphs) {
breakbeat.kitTransition.pendingMorphs = {};
}
breakbeat.kitTransition.pendingMorphs[voice] = {
target: targetPattern,
remaining: indices.length < 16 ? 16 - indices.length : 0
};
} else {
// Full replace
breakbeat.patterns[voice] = targetPattern;
replacedVoices.push(voice);
}
});
// Thin out drum density - max 3 drum voices per step
thinDrumDensity();
// Log what happened
const parts = [];
if (replacedVoices.length) parts.push(replacedVoices.join(', '));
if (morphedVoices.length) parts.push(`${morphedVoices.join(', ')} (morphing)`);
if (parts.length) logEvolution(` ↳ ${parts.join(', ')}`);
}
function thinDrumDensity() {
// Prevent drummer going ham - max 3 drum voices hitting same step
const drumVoices = ['kick', 'snare', 'hihat', 'clap', 'noise'];
const maxPerStep = 3;
for (let step = 0; step < 16; step++) {
const hitsThisStep = [];
drumVoices.forEach(v => {
if (breakbeat.patterns[v] && breakbeat.patterns[v][step]) {
hitsThisStep.push(v);
}
});
// If too dense, randomly remove some (but keep kick/snare priority)
if (hitsThisStep.length > maxPerStep) {
// Sort so kick/snare are kept, others removed first
const priority = ['kick', 'snare', 'hihat', 'clap', 'noise'];
hitsThisStep.sort((a, b) => priority.indexOf(a) - priority.indexOf(b));
// Remove excess from the end (lowest priority)
const toRemove = hitsThisStep.slice(maxPerStep);
toRemove.forEach(v => {
breakbeat.patterns[v][step] = 0;
});
}
}
}
function continuePendingMorphs() {
// Continue any step-by-step morphs from previous bar
const pm = breakbeat.kitTransition && breakbeat.kitTransition.pendingMorphs;
if (!pm) return;
Object.keys(pm).forEach(voice => {
const morph = pm[voice];
if (!morph || morph.remaining <= 0 || !morph.target) {
delete pm[voice];
return;
}
// Replace another 4-8 steps
const stepsToMorph = Math.min(morph.remaining, 4 + Math.floor(Math.random() * 5));
const currentPattern = breakbeat.patterns[voice];
const targetPattern = morph.target;
// Find steps that still differ
const differingIndices = [];
for (let i = 0; i < 16; i++) {
if (currentPattern[i] !== targetPattern[i]) {
differingIndices.push(i);
}
}
// Replace some of them
const toReplace = differingIndices.slice(0, stepsToMorph);
toReplace.forEach(i => {
currentPattern[i] = targetPattern[i];
});
morph.remaining -= toReplace.length;
if (morph.remaining <= 0 || differingIndices.length <= stepsToMorph) {
delete pm[voice];
}
});
}
function generateRandomPattern(density, steps) {
const pattern = Array(steps).fill(0);
for (let i = 0; i < steps; i++) {
pattern[i] = Math.random() < density ? 1 : 0;
}
return pattern;
}
function generateMelodicPattern(scale, density, steps) {
const pattern = Array(steps).fill(0);
for (let i = 0; i < steps; i++) {
if (Math.random() < density) {
pattern[i] = scale[Math.floor(Math.random() * scale.length)];
}
}
return pattern;
}
function voiceAllowedThisStep(voice, step) {
// Check mute/solo state from sequencer (with null checks)
if (breakbeat && breakbeat.voiceMute && breakbeat.voiceMute[voice]) return false;
// Check solo - if ANY voice is soloed, only allow soloed voices
if (breakbeat && breakbeat.voiceSolo) {
try {
const anySoloed = Object.values(breakbeat.voiceSolo).some(v => v);
if (anySoloed && !breakbeat.voiceSolo[voice]) {
// Also check if any voice of this TYPE is soloed (for ghost patterns)
const typeMatch = Object.keys(breakbeat.voiceSolo).find(k =>
breakbeat.voiceSolo[k] && k.startsWith(voice)
);
if (!typeMatch) return false;
}
} catch (e) { /* ignore solo check errors */ }
}
if (step === 0) {
if (breakbeat.barInstinct.downbeatDropAll) return false;
if (breakbeat.barInstinct.downbeatDrop[voice]) return false;
}
// Rally mode: if things got too sparse for too long, bring the band in.
// Bypass bar-level skip/div gating for core voices so they "play along".
if (breakbeat.rallyBarsRemaining > 0) {
if (voice === 'kick' || voice === 'snare' || voice === 'hihat' || voice === 'clap' || voice === 'bass' || voice === 'pad') {
return true;
}
}
if (breakbeat.barInstinct.barSkip[voice]) return false;
const div = breakbeat.barInstinct.barDiv[voice] || 1;
const phase = breakbeat.barInstinct.barPhase[voice] || 0;
if (div > 1) {
return (breakbeat.barCount % div) === phase;
}
return true;
}
const MIX_STYLES = [
{
name: 'DEFAULT',
kick: { startHz: 150, endHz: 50, sweep: 0.05, decay: 0.30, level: 0.80, click: 0.0 },
snare: { toneHz: 200, toneDecay: 0.10, noiseLevel: 0.40, noiseDecay: 0.15, noiseHz: 3000 },
bass: { filterMin: 400, filterMax: 1200, sweepUpProb: 0.20, qMin: 3, qMax: 7, detune: 0.005, sub: 0.50 }
},
{
name: 'TR-808',
kick: { startHz: 140, endHz: 48, sweep: 0.055, decay: 0.34, level: 0.78, click: 0.05 },
snare: { toneHz: 185, toneDecay: 0.12, noiseLevel: 0.33, noiseDecay: 0.17, noiseHz: 2600 },
bass: { filterMin: 300, filterMax: 900, sweepUpProb: 0.12, qMin: 2.5, qMax: 5.5, detune: 0.004, sub: 0.55 }
},
{
name: 'TR-909',
kick: { startHz: 165, endHz: 52, sweep: 0.045, decay: 0.26, level: 0.82, click: 0.10 },
snare: { toneHz: 220, toneDecay: 0.085, noiseLevel: 0.46, noiseDecay: 0.13, noiseHz: 3600 },
bass: { filterMin: 450, filterMax: 1400, sweepUpProb: 0.22, qMin: 3, qMax: 7.5, detune: 0.006, sub: 0.45 }
},
{
name: 'CR-78',
kick: { startHz: 120, endHz: 52, sweep: 0.06, decay: 0.28, level: 0.70, click: 0.0 },
snare: { toneHz: 170, toneDecay: 0.12, noiseLevel: 0.22, noiseDecay: 0.12, noiseHz: 2200 },
bass: { filterMin: 320, filterMax: 1000, sweepUpProb: 0.12, qMin: 2.5, qMax: 6, detune: 0.004, sub: 0.55 }
},
{
name: 'TB-303',
kick: { startHz: 150, endHz: 45, sweep: 0.05, decay: 0.28, level: 0.75, click: 0.0 },
snare: { toneHz: 200, toneDecay: 0.10, noiseLevel: 0.35, noiseDecay: 0.14, noiseHz: 3200 },
bass: { filterMin: 200, filterMax: 2000, sweepUpProb: 0.80, qMin: 8, qMax: 12, detune: 0.008, sub: 0.20 }
},
{
name: 'Blue Monday',
kick: { startHz: 160, endHz: 50, sweep: 0.048, decay: 0.30, level: 0.80, click: 0.08 },
snare: { toneHz: 210, toneDecay: 0.09, noiseLevel: 0.42, noiseDecay: 0.14, noiseHz: 3400 },
bass: { filterMin: 400, filterMax: 1200, sweepUpProb: 0.35, qMin: 4, qMax: 6.5, detune: 0.005, sub: 0.40 }
}
];
function setMixStyleByName(name) {
const preset = MIX_STYLES.find(s => s.name === name);
if (!preset) return;
const prevStyle = breakbeat.mixStyleName;
breakbeat.mixStyleName = preset.name;
if (prevStyle !== preset.name) {
logEvolution(`MIX: ${preset.name}`);
}
breakbeat.mixStyle = {
kick: { ...preset.kick },
snare: { ...preset.snare },
bass: { ...preset.bass }
};
}
function playKick(time, vol = 0.4, pitchMult = 1.0) {
const ctx = initAudio();
const osc = ctx.createOscillator();
const gain = ctx.createGain();
const p = (breakbeat && breakbeat.mixStyle && breakbeat.mixStyle.kick) ? breakbeat.mixStyle.kick : null;
// Deep settings ALWAYS take priority for exposed params (user-controlled)
const deepPitch = kickDeepSettings.pitch || 55;
const deepDecay = (kickDeepSettings.decay || 150) / 1000; // Convert ms to seconds
const deepPunch = (kickDeepSettings.punch || 50) / 100; // 0-1 range
const deepDrive = (kickDeepSettings.drive || 20) / 100; // 0-1 range
osc.type = 'sine';
// Deep settings always win for PITCH and DECAY (exposed sliders)
// Punch affects starting frequency (more punch = higher start = more "click")
const baseStartHz = 80 + (deepPunch * 200); // 80-280Hz based on punch
const startHz = baseStartHz * pitchMult;
const endHz = deepPitch * pitchMult; // Always use deep pitch
const sweep = 0.03 + (1 - deepPunch) * 0.04; // Faster sweep with more punch
const decay = deepDecay; // Always use deep decay
const level = (p ? p.level : 0.80) * (vol / 0.4);
// Drive adds click transient
const click = deepDrive * 0.4;
osc.frequency.setValueAtTime(startHz, time);
osc.frequency.exponentialRampToValueAtTime(Math.max(endHz, 20), time + sweep);
gain.gain.setValueAtTime(level, time);
gain.gain.exponentialRampToValueAtTime(0.001, time + decay);
osc.connect(gain);
gain.connect(musicGain);
osc.start(time);
osc.stop(time + decay + 0.01);
if (click > 0.001) {
const clickOsc = ctx.createOscillator();
const clickGain = ctx.createGain();
clickOsc.type = 'square';
clickOsc.frequency.setValueAtTime(1800 + (deepDrive * 1200), time); // 1800-3000Hz based on drive
clickGain.gain.setValueAtTime(level * click, time);
clickGain.gain.exponentialRampToValueAtTime(0.001, time + 0.02);
clickOsc.connect(clickGain);
clickGain.connect(musicGain);
clickOsc.start(time);
clickOsc.stop(time + 0.03);
}
}
function playSnare(time, vol = 0.35, pitchMult = 1.0) {
const ctx = initAudio();
const p = (breakbeat && breakbeat.mixStyle && breakbeat.mixStyle.snare) ? breakbeat.mixStyle.snare : null;
// Deep settings ALWAYS take priority for exposed params (SNAPPY and DECAY)
const deepTone = snareDeepSettings.tone || 200;
const deepSnappy = (snareDeepSettings.snappy || 60) / 100; // 0-1 range
const deepDecay = (snareDeepSettings.decay || 150) / 1000; // Convert ms to seconds
const deepNoise = (snareDeepSettings.noise || 70) / 100; // 0-1 range
// Deep settings always win for exposed sliders
const toneHz = deepTone * pitchMult;
const toneDecay = deepDecay * 0.6; // Tone decays faster than overall
// Snappy affects the attack brightness and initial level
const noiseLevel = (0.2 + deepNoise * 0.4) * (vol / 0.35);
const noiseDecay = deepDecay; // Always use deep decay
// Snappy affects noise frequency - more snappy = higher, brighter
const noiseHz = (2000 + deepSnappy * 4000) * pitchMult;
// Tone
const osc = ctx.createOscillator();
const oscGain = ctx.createGain();
osc.type = 'sine';
osc.frequency.setValueAtTime(toneHz, time);
// Snappy affects initial tone attack
oscGain.gain.setValueAtTime(0.3 + deepSnappy * 0.4, time);
oscGain.gain.exponentialRampToValueAtTime(0.001, time + toneDecay);
osc.connect(oscGain);
oscGain.connect(musicGain);
osc.start(time);
osc.stop(time + Math.max(0.06, toneDecay + 0.03));
// Noise
const noise = ctx.createBufferSource();
noise.buffer = noiseBuffers.short;
const filter = ctx.createBiquadFilter();
filter.type = 'bandpass';
filter.frequency.value = noiseHz;
filter.Q.value = 1 + deepSnappy * 2; // More resonant with more snappy
const noiseGain = ctx.createGain();
noiseGain.gain.setValueAtTime(noiseLevel, time);
noiseGain.gain.exponentialRampToValueAtTime(0.001, time + noiseDecay);
noise.connect(filter);
filter.connect(noiseGain);
noiseGain.connect(musicGain);
noise.start(time);
}
// Position-based open hat logic - musical decisions based on step position
function shouldHatBeOpen(step16, mixDensity, isChorus, isRally) {
// step16 is 0-15 (16th notes in a bar)
// Musical positions for opens:
const offbeats = [2, 6, 10, 14]; // "&" positions (between beats)
const beforeSnare = [3, 11]; // Just before beats 2 and 4
const accentPositions = [7, 15]; // "&" of 2 and "&" of 4 (groove accents)
// Base probability from MIX slider (0-1)
let openProb = mixDensity;
// Energy boost from musical context
if (isChorus) openProb *= 2.5; // Chorus = more opens
if (isRally) openProb *= 1.8; // Rally = energy boost
// Position-based logic (opens only on musical positions)
const isOffbeat = offbeats.includes(step16);
const isBeforeSnare = beforeSnare.includes(step16);
const isAccent = accentPositions.includes(step16);
// Never open on downbeats (0, 4, 8, 12) - keeps it tight
const isDownbeat = [0, 4, 8, 12].includes(step16);
if (isDownbeat) return false;
// Higher probability on accent positions, lower on plain offbeats
if (isAccent) return Math.random() < (openProb * 3);
if (isBeforeSnare) return Math.random() < (openProb * 2);
if (isOffbeat) return Math.random() < openProb;
// Other positions: very rare opens
return Math.random() < (openProb * 0.3);
}
function playHiHat(time, vol = 0.25, pitchMult = 1.0, forceOpen = null, step = -1, voiceId = 'hat0') {
const ctx = initAudio();
// Get hat motif mode for this voice (default to '808' if not set)
const hatMode = seqHatMotif[voiceId] || seqHatMotif['hat0'] || '808';
// Use deep settings
const deepTone = hihatDeepSettings.tone || 8000;
const deepClosed = (hihatDeepSettings.closed || 50) / 1000; // Convert ms to seconds
const deepOpen = (hihatDeepSettings.open || 200) / 1000;
const deepRing = (hihatDeepSettings.ring || 30) / 100; // 0-1 range
const deepMix = (hihatDeepSettings.mix ?? 5) / 100; // 0-1 range, base open density
// Determine if open or closed using position-based logic
let isOpen;
if (forceOpen !== null) {
isOpen = forceOpen;
} else if (step >= 0) {
// Use musical position logic
const step16 = step % 16;
const isChorus = breakbeat && breakbeat.chorusMode;
const isRally = breakbeat && breakbeat.rallyBarsRemaining > 0;
isOpen = shouldHatBeOpen(step16, deepMix, isChorus, isRally);
} else {
// Fallback to simple probability (preview, etc)
isOpen = Math.random() < deepMix;
}
// Duration: open rings for exactly 1 sixteenth longer than closed
const decay = isOpen ? deepOpen : deepClosed;
// Route to appropriate synthesis engine based on hat motif mode
if (hatMode === '606') {
play606Hat(time, vol, pitchMult, isOpen, decay, deepTone, deepRing);
} else if (hatMode === 'noise') {
playNoiseHat(time, vol, pitchMult, isOpen, decay, deepTone, deepRing);
} else if (hatMode === 'chip' || hatMode === 'gb' || hatMode === 'psg') {
playChipHat(time, vol, pitchMult, isOpen, decay, deepTone, hatMode);
} else {
// Default: 808-style (or random if empty)
play808Hat(time, vol, pitchMult, isOpen, decay, deepTone, deepRing);
}
}
// === HAT SYNTHESIS ENGINES ===
function play808Hat(time, vol, pitchMult, isOpen, decay, tone, ring) {
const ctx = initAudio();
// === IMPROVED SYNTHESIS: Metallic partials + filtered noise ===
// 1. Noise layer (sizzle/air) - band-passed for brightness
const noise = ctx.createBufferSource();
noise.buffer = noiseBuffers.short;
const noiseBand = ctx.createBiquadFilter();
noiseBand.type = 'bandpass';
noiseBand.frequency.value = (tone + 2000) * pitchMult;
noiseBand.Q.value = 2;
const noiseHP = ctx.createBiquadFilter();
noiseHP.type = 'highpass';
noiseHP.frequency.value = tone * pitchMult * 0.8;
const noiseGain = ctx.createGain();
noiseGain.gain.setValueAtTime(vol * 0.5, time);
noiseGain.gain.exponentialRampToValueAtTime(0.001, time + decay);
noise.connect(noiseBand);
noiseBand.connect(noiseHP);
noiseHP.connect(noiseGain);
noiseGain.connect(musicGain);
noise.start(time);
noise.stop(time + decay + 0.02);
// 2. Metallic partials layer - AUTHENTIC 808 CYMBAL APPROACH
// 6 square oscillators at inharmonic frequencies, dual band-pass into HP
// Frequencies from TR-808 cymbal circuit analysis
const f808 = [205.3, 304.4, 369.6, 522.7, 540.0, 800.0];
const partialCount = isOpen ? 6 : 4; // Open hats get all 6, closed gets 4
// Mix node for all oscillators
const metalMix = ctx.createGain();
metalMix.gain.value = vol * ring * 0.5;
// Create oscillators with per-partial envelopes
for (let i = 0; i < partialCount; i++) {
// Slight random detune per hit (±2%) for animated inharmonicity
const freq = f808[i] * pitchMult * (0.98 + Math.random() * 0.04);
const osc = ctx.createOscillator();
osc.type = 'square';
osc.frequency.value = freq;
// Per-oscillator envelope: higher partials decay faster
const oscGain = ctx.createGain();
const partialVol = 0.18 / Math.sqrt(i + 1); // Gradual rolloff
const partialDecay = decay * (0.7 + (partialCount - i) * 0.08); // Lower = longer
oscGain.gain.setValueAtTime(partialVol, time);
oscGain.gain.exponentialRampToValueAtTime(0.001, time + partialDecay);
osc.connect(oscGain);
oscGain.connect(metalMix);
osc.start(time);
osc.stop(time + partialDecay + 0.01);
}
// DUAL BAND-PASS FILTERS (808 style: 3.44kHz and 7.1kHz metal bands)
const band1 = ctx.createBiquadFilter();
band1.type = 'bandpass';
band1.frequency.value = 3440 * pitchMult;
band1.Q.value = 3;
const band2 = ctx.createBiquadFilter();
band2.type = 'bandpass';
band2.frequency.value = 7100 * pitchMult;
band2.Q.value = 3;
// Split signal into both bands
const band1Gain = ctx.createGain();
band1Gain.gain.value = 0.7;
const band2Gain = ctx.createGain();
band2Gain.gain.value = 1.0; // Brighter band slightly louder
metalMix.connect(band1);
metalMix.connect(band2);
band1.connect(band1Gain);
band2.connect(band2Gain);
// Merge bands and high-pass to keep only bright metal
const metalHP = ctx.createBiquadFilter();
metalHP.type = 'highpass';
metalHP.frequency.value = 5500 * pitchMult;
band1Gain.connect(metalHP);
band2Gain.connect(metalHP);
metalHP.connect(musicGain);
}
function play606Hat(time, vol, pitchMult, isOpen, decay, tone, ring) {
const ctx = initAudio();
// 606-style: 6 square oscillators at different frequencies, dual bandpass at 7.1kHz and 3.44kHz
const f606 = [246.4, 308.0, 367.0, 415.0, 437.0, 619.0];
const partialCount = isOpen ? 6 : 4;
const metalMix = ctx.createGain();
metalMix.gain.value = vol * ring * 0.4;
for (let i = 0; i < partialCount; i++) {
const freq = f606[i] * pitchMult * (0.98 + Math.random() * 0.04);
const osc = ctx.createOscillator();
osc.type = 'square';
osc.frequency.value = freq;
const oscGain = ctx.createGain();
const partialVol = 0.15 / Math.sqrt(i + 1);
const partialDecay = decay * (0.6 + (partialCount - i) * 0.1);
oscGain.gain.setValueAtTime(partialVol, time);
oscGain.gain.exponentialRampToValueAtTime(0.001, time + partialDecay);
osc.connect(oscGain);
oscGain.connect(metalMix);
osc.start(time);
osc.stop(time + partialDecay + 0.01);
}
// 606 dual bandpass: 7.1kHz (bright) and 3.44kHz (body)
const bandHi = ctx.createBiquadFilter();
bandHi.type = 'bandpass';
bandHi.frequency.value = 7100 * pitchMult;
bandHi.Q.value = 4;
const bandLo = ctx.createBiquadFilter();
bandLo.type = 'bandpass';
bandLo.frequency.value = 3440 * pitchMult;
bandLo.Q.value = 3;
const bandHiGain = ctx.createGain();
bandHiGain.gain.value = 1.0;
const bandLoGain = ctx.createGain();
bandLoGain.gain.value = 0.3; // Body at lower level
metalMix.connect(bandHi);
metalMix.connect(bandLo);
bandHi.connect(bandHiGain);
bandLo.connect(bandLoGain);
const hp = ctx.createBiquadFilter();
hp.type = 'highpass';
hp.frequency.value = 3000 * pitchMult;
bandHiGain.connect(hp);
bandLoGain.connect(hp);
hp.connect(musicGain);
// Noise layer (lighter than 808)
const noise = ctx.createBufferSource();
noise.buffer = noiseBuffers.short;
const noiseBand = ctx.createBiquadFilter();
noiseBand.type = 'bandpass';
noiseBand.frequency.value = (tone + 1500) * pitchMult;
noiseBand.Q.value = 2.5;
const noiseHP = ctx.createBiquadFilter();
noiseHP.type = 'highpass';
noiseHP.frequency.value = tone * pitchMult * 0.7;
const noiseGain = ctx.createGain();
noiseGain.gain.setValueAtTime(vol * 0.3, time);
noiseGain.gain.exponentialRampToValueAtTime(0.001, time + decay);
noise.connect(noiseBand);
noiseBand.connect(noiseHP);
noiseHP.connect(noiseGain);
noiseGain.connect(musicGain);
noise.start(time);
noise.stop(time + decay + 0.02);
}
function playNoiseHat(time, vol, pitchMult, isOpen, decay, tone, ring) {
const ctx = initAudio();
// Analog noise-based hat (DrumBrute style): filtered noise with sharp envelope
const noise = ctx.createBufferSource();
noise.buffer = noiseBuffers.short;
// Color maps to filter frequency (darker → brighter)
const color = ring; // Use ring param as "color"
const band = ctx.createBiquadFilter();
band.type = 'bandpass';
band.frequency.value = (8000 + color * 4000) * pitchMult;
band.Q.value = 2 + color * 5;
const highpass = ctx.createBiquadFilter();
highpass.type = 'highpass';
highpass.frequency.value = 4000 * pitchMult;
const env = ctx.createGain();
env.gain.setValueAtTime(0.0001, time);
env.gain.linearRampToValueAtTime(vol * 0.8, time + 0.0005);
env.gain.exponentialRampToValueAtTime(0.001, time + decay);
noise.connect(band);
band.connect(highpass);
highpass.connect(env);
env.connect(musicGain);
noise.start(time);
noise.stop(time + decay + 0.02);
}
// LFSR noise buffer for chip hats (created once, reused)
let lfsrNoiseBuffer = null;
function createLFSRNoiseBuffer(lengthSeconds = 1, clockDiv = 4, taps = [0, 1]) {
if (lfsrNoiseBuffer) return lfsrNoiseBuffer; // Reuse if already created
const ctx = initAudio();
const rate = ctx.sampleRate;
const length = Math.floor(rate * lengthSeconds);
const buffer = ctx.createBuffer(1, length, rate);
const data = buffer.getChannelData(0);
let reg = 1; // 15-bit LFSR seed
for (let i = 0; i < length; i++) {
if (i % clockDiv === 0) {
let fb = 0;
taps.forEach(t => { fb ^= (reg >> t) & 1; });
reg = (reg >> 1) | (fb << 14);
}
const bit = reg & 1;
data[i] = bit ? 1 : -1;
}
lfsrNoiseBuffer = buffer;
return buffer;
}
function playChipHat(time, vol, pitchMult, isOpen, decay, tone, mode) {
const ctx = initAudio();
// Chip/PSG-style hat using LFSR noise
let buffer, clockDiv, taps, playbackRate;
if (mode === 'gb') {
// Game Boy: shorter LFSR, pitchy noise
buffer = createLFSRNoiseBuffer(0.5, 2, [0, 6]);
clockDiv = 2;
playbackRate = 1.0;
} else if (mode === 'psg') {
// SN76489/PSG: classic 15-bit LFSR
buffer = createLFSRNoiseBuffer(1.0, 3, [0, 1]);
clockDiv = 3;
playbackRate = 1.0;
} else {
// Generic chip (NES-style)
buffer = createLFSRNoiseBuffer(0.5, 4, [0, 1]);
clockDiv = 4;
playbackRate = 1.0;
}
const src = ctx.createBufferSource();
src.buffer = buffer;
src.playbackRate.value = playbackRate * pitchMult;
// Optional light highpass for crispness
const hp = ctx.createBiquadFilter();
hp.type = 'highpass';
hp.frequency.value = 2000 * pitchMult;
const env = ctx.createGain();
env.gain.setValueAtTime(0.0001, time);
env.gain.linearRampToValueAtTime(vol * 0.7, time + 0.0005);
env.gain.exponentialRampToValueAtTime(0.0001, time + decay);
src.connect(hp);
hp.connect(env);
env.connect(musicGain);
src.start(time);
src.stop(time + Math.min(decay + 0.02, 0.1));
}
function playXSIDSizzleHat(time, vol = 0.35) {
const ctx = initAudio();
const noise = ctx.createBufferSource();
noise.buffer = noiseBuffers.short;
// XSID-ish: tight bandpass in the highs for a metallic sizzle
const band = ctx.createBiquadFilter();
band.type = 'bandpass';
band.frequency.setValueAtTime(7600 + Math.random() * 1800, time);
band.Q.setValueAtTime(7.5 + Math.random() * 4.5, time);
const hp = ctx.createBiquadFilter();
hp.type = 'highpass';
hp.frequency.setValueAtTime(5200 + Math.random() * 2000, time);
const gain = ctx.createGain();
const dur = 0.2 + Math.random() * 0.2;
gain.gain.setValueAtTime(0.0001, time);
gain.gain.exponentialRampToValueAtTime(vol, time + 0.004);
gain.gain.exponentialRampToValueAtTime(0.0001, time + dur);
noise.connect(band);
band.connect(hp);
hp.connect(gain);
gain.connect(musicGain);
noise.start(time);
noise.stop(time + dur + 0.02);
}
function playClap(time, vol = 0.4) {
const ctx = initAudio();
const gain = ctx.createGain();
gain.gain.setValueAtTime(vol, time);
gain.gain.exponentialRampToValueAtTime(0.001, time + 0.15);
gain.connect(musicGain);
for (let i = 0; i < 3; i++) {
const noise = ctx.createBufferSource();
noise.buffer = noiseBuffers.short;
const filter = ctx.createBiquadFilter();
filter.type = 'bandpass';
filter.frequency.value = 2500 + Math.random() * 500;
filter.Q.value = 2;
noise.connect(filter);
filter.connect(gain);
noise.start(time + i * 0.01);
}
}
// === SID-STYLE BASS SYNTH ===
// Detuned pulse waves like C64 SID chip
// With filter sweep, pitch bend, and occasional vibrato
// === SMART BASS ROUTER ===
// Routes to appropriate bass engine based on bass motif (or legacy seqBassStyle)
function playSmartBass(time, note = 36, vol = 0.25, voiceId = 'bass0') {
// Get bass motif mode for this voice (default to legacy seqBassStyle if not set)
// Handle empty string as "not set" - should fall back to default
let bassMode = seqBassMotif[voiceId];
if (!bassMode || bassMode === '') {
bassMode = seqBassMotif['bass0'];
}
if (!bassMode || bassMode === '') {
bassMode = seqBassStyle;
}
if (!bassMode || bassMode === '') {
bassMode = 'classic'; // Final fallback to default
}
if (bassMode === 'tuss') {
playTussBass(time, note, vol);
} else if (bassMode === '808buzz') {
play808BuzzBass(time, note, vol);
} else if (bassMode === 'squarepusher') {
// Squarepusher as bass style: also very quiet, max 0.8%
playSquarepusherBass(time, note, Math.min(vol, 0.008));
} else {
// Default: classic SID bass
playBass(time, note, vol);
}
}
function playBass(time, note = 36, vol = 0.25) {
const ctx = initAudio();
// Get deep settings if applicable (optional for bass)
const deep = getLeadSettingsForVoice('bass');
const octShift = deep ? deep.octave * 12 : 0;
const driftC = deep && deep.drift ? (Math.random() - 0.5) * 2 * deep.drift : 0;
const detuneC = (deep ? deep.detune : 0) + driftC;
const volMult = deep ? deep.volume / 100 : 1;
// Convert MIDI note to frequency
const freq = 440 * Math.pow(2, (note + octShift - 69 + detuneC / 100) / 12);
const p = (breakbeat && breakbeat.mixStyle && breakbeat.mixStyle.bass) ? breakbeat.mixStyle.bass : null;
const detune = p ? p.detune : 0.005;
const subLevel = p ? p.sub : 0.50;
// Random expression per note
const doPitchBend = Math.random() < 0.3; // 30% chance
const doVibrato = Math.random() < 0.15; // 15% chance
const filterSweepUp = Math.random() < (p ? p.sweepUpProb : 0.2);
const filterMin = p ? p.filterMin : 400;
const filterMax = p ? p.filterMax : 1200;
const filterStart = filterMin + Math.random() * Math.max(1, (filterMax - filterMin));
const filterEnd = filterSweepUp ? filterStart * 2 : filterStart * 0.3;
// OSC 1 - Sawtooth (approximates pulse)
const osc1 = ctx.createOscillator();
osc1.type = 'sawtooth';
// OSC 2 - Slightly detuned for fat sound
const osc2 = ctx.createOscillator();
osc2.type = 'sawtooth';
// OSC 3 - Sub octave for weight
const osc3 = ctx.createOscillator();
osc3.type = 'sine';
// Pitch bend - slide up or down into the note
if (doPitchBend) {
const bendDir = Math.random() < 0.5 ? 0.95 : 1.05; // Bend from below or above
osc1.frequency.setValueAtTime(freq * bendDir, time);
osc1.frequency.exponentialRampToValueAtTime(freq, time + 0.05);
osc2.frequency.setValueAtTime(freq * 1.005 * bendDir, time);
osc2.frequency.exponentialRampToValueAtTime(freq * 1.005, time + 0.05);
osc3.frequency.setValueAtTime(freq * 0.5 * bendDir, time);
osc3.frequency.exponentialRampToValueAtTime(freq * 0.5, time + 0.05);
} else {
osc1.frequency.setValueAtTime(freq, time);
osc2.frequency.setValueAtTime(freq * (1 + detune), time);
osc3.frequency.setValueAtTime(freq * 0.5, time);
}
// Vibrato LFO - subtle pitch wobble
if (doVibrato) {
const lfo = ctx.createOscillator();
const lfoGain = ctx.createGain();
lfo.type = 'sine';
lfo.frequency.value = 5 + Math.random() * 3; // 5-8 Hz vibrato rate
lfoGain.gain.value = freq * 0.015; // ~25 cents depth
lfo.connect(lfoGain);
lfoGain.connect(osc1.frequency);
lfoGain.connect(osc2.frequency);
// Delay vibrato onset slightly
lfoGain.gain.setValueAtTime(0, time);
lfoGain.gain.linearRampToValueAtTime(freq * 0.015, time + 0.1);
lfo.start(time);
lfo.stop(time + 0.5);
}
// Filter - use bassDeepSettings, then lead deep settings, then mixStyle
const filter = ctx.createBiquadFilter();
filter.type = deep ? deep.filterType : 'lowpass';
// Priority: bassDeepSettings.filter > deep.filterCut > mixStyle filterStart
const bassFilterCut = bassDeepSettings.filter || 1200;
const bassRes = bassDeepSettings.res || 5;
const fStart = bassDeepSettings.filter ? bassFilterCut : (deep ? deep.filterCut : filterStart);
const fEnd = bassDeepSettings.filter ? (bassFilterCut * 0.25) : (deep ? deep.filterCut * 0.3 : filterEnd);
filter.frequency.setValueAtTime(fStart, time);
filter.frequency.exponentialRampToValueAtTime(Math.max(20, fEnd), time + 0.25);
const qMin = p ? p.qMin : 3;
const qMax = p ? p.qMax : 7;
// Use bassDeepSettings.res if set, else lead deep, else random
filter.Q.value = bassDeepSettings.res ? bassRes : (deep ? safariSafe('maxResonance', deep.filterRes) : qMin + Math.random() * Math.max(0.1, (qMax - qMin)));
// Amp envelope - use deep ADSR if available
const finalVol = vol * volMult;
const atkMs = deep ? deep.attack : 10;
const decMs = deep ? deep.decay : 90;
const susLvl = deep ? deep.sustain / 100 : 0.7;
const relMs = deep ? deep.release : 250;
const gain = ctx.createGain();
gain.gain.setValueAtTime(0.0001, time);
gain.gain.exponentialRampToValueAtTime(finalVol, time + atkMs / 1000);
gain.gain.exponentialRampToValueAtTime(Math.max(0.0001, finalVol * susLvl), time + (atkMs + decMs) / 1000);
gain.gain.exponentialRampToValueAtTime(0.0001, time + (atkMs + decMs + relMs) / 1000);
// Mix oscillators
const mixer = ctx.createGain();
mixer.gain.value = subLevel;
osc1.connect(mixer);
osc2.connect(mixer);
osc3.connect(mixer);
mixer.connect(filter);
filter.connect(gain);
gain.connect(musicGain);
// FX sends for bass
if (deep) {
const delaySend = deep.delaySend / 100;
const reverbSend = deep.reverbSend / 100;
if (delaySend > 0 && leadFxDelay) {
const ds = ctx.createGain(); ds.gain.value = delaySend * 0.3;
gain.connect(ds); ds.connect(leadFxDelay);
leadFxDelayWet.gain.value = Math.max(leadFxDelayWet.gain.value, 0.6);
}
if (reverbSend > 0 && leadFxReverb) {
const rs = ctx.createGain(); rs.gain.value = reverbSend * 0.2;
gain.connect(rs); rs.connect(leadFxReverb);
leadFxReverbWet.gain.value = Math.max(leadFxReverbWet.gain.value, 0.5);
}
}
osc1.start(time);
osc2.start(time);
osc3.start(time);
osc1.stop(time + 0.5);
osc2.stop(time + 0.5);
osc3.stop(time + 0.5);
}
// === TUSS BASS V2 (Dynamic Aphex Logic) ===
// "Rubbery & Warm" by default, transforming to "Sharp & Edgy" on accents
// Adds "Resonant Glow" (reverb/delay bloom) when tempo is slow or spacious
function playTussBass(time, note = 36, vol = 0.25) {
const ctx = initAudio();
// 1. ANALYZE CONTEXT (The "Room to Fill" logic)
const isSlow = breakbeat.bpm < 100; // Slower tempos = more glow
const isSparse = Math.random() < 0.4; // 40% chance to treat this note as "spacious"
const enableGlow = isSlow || isSparse;
// 2. DETERMINE CHARACTER ("Rubber" vs "Sharp Edge")
// High velocity (> 0.75) or random chance triggers "Sharp" mode
const isSharp = (vol > 0.75) || (Math.random() < 0.25);
// Get deep settings if applicable
const deep = getLeadSettingsForVoice('bass');
const octShift = deep ? deep.octave * 12 : 0;
const driftC = deep && deep.drift ? (Math.random() - 0.5) * 2 * deep.drift : 0;
const detuneC = (deep ? deep.detune : 0) + driftC;
const volMult = deep ? deep.volume / 100 : 1;
// Convert MIDI note to frequency
const freq = 440 * Math.pow(2, (note + octShift - 69 + detuneC / 100) / 12);
// Tuss modulation parameters (Dynamic based on character)
const tussParams = {
// Sharp: Fast, jagged LFOs. Rubber: Slower, rolling LFOs.
cutoffLfoRate: isSharp ? (8 + Math.random() * 8) : (3 + Math.random() * 5),
cutoffLfoDepth: isSharp ? (2000 + Math.random() * 2000) : (800 + Math.random() * 800),
qLfoRate: isSharp ? (6 + Math.random() * 6) : (2 + Math.random() * 4),
qLfoDepth: isSharp ? (8 + Math.random() * 8) : (4 + Math.random() * 4),
// Sharp: Higher base Q for scream. Rubber: Lower Q for warmth.
baseQ: isSharp ? (15 + Math.random() * 10) : (8 + Math.random() * 6),
// Sharp: Instant attack. Rubber: 15-30ms "squish" attack.
filterAttackTime: isSharp ? 0.005 : (0.015 + Math.random() * 0.025),
filterPeak: freq * (isSharp ? 5 : 3), // Sharp opens brighter
useQMod: true // Always modulate Q for that Tuss life
};
// Main oscillator (Sawtooth for harmonics)
const osc1 = ctx.createOscillator();
osc1.type = 'sawtooth';
osc1.frequency.setValueAtTime(freq, time);
// Detuned oscillator (Slightly wider in Rubber mode)
const osc2 = ctx.createOscillator();
osc2.type = 'sawtooth';
const detuneAmt = isSharp ? 1.005 : 1.012; // Rubber = wider/warmer
osc2.frequency.setValueAtTime(freq * detuneAmt, time);
// Sub oscillator (Sine)
const oscSub = ctx.createOscillator();
oscSub.type = 'sine';
oscSub.frequency.setValueAtTime(freq * 0.5, time);
// === FILTER STAGE 1: THE MODULATOR (Movement) ===
const filter = ctx.createBiquadFilter();
filter.type = 'lowpass';
filter.Q.value = tussParams.baseQ;
// LFO 1: Cutoff Wobble
const cutoffLfo = ctx.createOscillator();
cutoffLfo.type = isSharp ? 'sawtooth' : 'sine'; // Saw for edge, Sine for rubber
cutoffLfo.frequency.value = tussParams.cutoffLfoRate;
const cutoffLfoGain = ctx.createGain();
cutoffLfoGain.gain.value = tussParams.cutoffLfoDepth;
cutoffLfo.connect(cutoffLfoGain);
cutoffLfoGain.connect(filter.frequency);
// LFO 2: Resonance Wobble
if (tussParams.useQMod) {
const qLfo = ctx.createOscillator();
qLfo.type = 'triangle';
qLfo.frequency.value = tussParams.qLfoRate;
const qLfoGain = ctx.createGain();
qLfoGain.gain.value = tussParams.qLfoDepth;
qLfo.connect(qLfoGain);
qLfoGain.connect(filter.Q);
qLfo.start(time);
qLfo.stop(time + 0.5);
}
// Filter Envelope
filter.frequency.setValueAtTime(200, time);
filter.frequency.exponentialRampToValueAtTime(tussParams.filterPeak, time + tussParams.filterAttackTime);
// Sharp: Fast decay. Rubber: Longer, rounder decay.
const decayTime = isSharp ? 0.15 : 0.35;
filter.frequency.exponentialRampToValueAtTime(300, time + decayTime);
// === FILTER STAGE 2: THE SMOOTHER (Rounding the edges) ===
// Only active in "Rubber" mode to tame digital harshness
const smoother = ctx.createBiquadFilter();
smoother.type = 'lowpass';
if (!isSharp) {
smoother.frequency.value = 3000; // Roll off super-highs
smoother.Q.value = 1.0; // Gentle slope
} else {
smoother.frequency.value = 20000; // Open fully for sharp mode
}
// Soft Waveshaping (Saturation)
const shaper = ctx.createWaveShaper();
const curve = new Float32Array(256);
const drive = isSharp ? 4.0 : 1.5; // More drive for sharp sounds
for (let i = 0; i < 256; i++) {
const x = (i - 128) / 128;
curve[i] = Math.tanh(x * drive); // Soft clip
}
shaper.curve = curve;
// Amp Envelope
const finalVol = vol * volMult;
const gain = ctx.createGain();
gain.gain.setValueAtTime(0.0001, time);
gain.gain.exponentialRampToValueAtTime(finalVol * 1.0, time + 0.005);
// Glow Logic: Longer release/sustain if allowing glow
if (enableGlow) {
// Long tail
gain.gain.exponentialRampToValueAtTime(finalVol * 0.6, time + 0.2);
gain.gain.exponentialRampToValueAtTime(0.0001, time + 0.8); // Long release
} else {
// Tight
gain.gain.exponentialRampToValueAtTime(finalVol * 0.7, time + 0.08);
gain.gain.exponentialRampToValueAtTime(0.0001, time + 0.35); // Short release
}
// Oscillator Mixer
const mixer = ctx.createGain();
mixer.gain.value = 0.5;
// Chain: Oscs -> Mixer -> Main Filter -> Smoother -> Shaper -> Gain -> Out
osc1.connect(mixer);
osc2.connect(mixer);
oscSub.connect(mixer);
mixer.connect(filter);
filter.connect(smoother);
smoother.connect(shaper);
shaper.connect(gain);
gain.connect(musicGain); // Main out
// === RESONANT GLOW SENDS (Reverb/Delay) ===
if (deep && enableGlow) {
// Boost sends for the "Glow" effect
const baseDelay = deep.delaySend / 100;
const baseReverb = deep.reverbSend / 100;
// If "Glow" is active, minimum 40% send, else use settings
const glowAmt = 0.4;
if (leadFxDelay) {
const ds = ctx.createGain();
ds.gain.value = Math.max(baseDelay, glowAmt) * 0.5;
gain.connect(ds); ds.connect(leadFxDelay);
// Gently boost the wet mix on the bus if needed
if (leadFxDelayWet.gain.value < 0.4) leadFxDelayWet.gain.setTargetAtTime(0.5, time, 0.1);
}
if (leadFxReverb) {
const rs = ctx.createGain();
rs.gain.value = Math.max(baseReverb, glowAmt) * 0.6; // Heavy reverb send
gain.connect(rs); rs.connect(leadFxReverb);
if (leadFxReverbWet.gain.value < 0.4) leadFxReverbWet.gain.setTargetAtTime(0.5, time, 0.1);
}
} else if (deep) {
// Normal Sends
const delaySend = deep.delaySend / 100;
const reverbSend = deep.reverbSend / 100;
if (delaySend > 0 && leadFxDelay) {
const ds = ctx.createGain(); ds.gain.value = delaySend * 0.3;
gain.connect(ds); ds.connect(leadFxDelay);
}
if (reverbSend > 0 && leadFxReverb) {
const rs = ctx.createGain(); rs.gain.value = reverbSend * 0.2;
gain.connect(rs); rs.connect(leadFxReverb);
}
}
// Start
osc1.start(time);
osc2.start(time);
oscSub.start(time);
cutoffLfo.start(time);
// Stop (extended duration if glowing)
const stopTime = time + (enableGlow ? 1.0 : 0.5);
osc1.stop(stopTime);
osc2.stop(stopTime);
oscSub.stop(stopTime);
cutoffLfo.stop(stopTime);
}
// === 808 BUZZ BASS (Classic 1988 NYC Dr. Dre Style) ===
// The "pop buzz ratchet trick" - sharp attack, buzzy saturation, deep sub
function play808BuzzBass(time, note = 36, vol = 0.25) {
const ctx = initAudio();
// Get deep settings if applicable
const deep = getLeadSettingsForVoice('bass');
const octShift = deep ? deep.octave * 12 : 0;
const driftC = deep && deep.drift ? (Math.random() - 0.5) * 2 * deep.drift : 0;
const detuneC = (deep ? deep.detune : 0) + driftC;
const volMult = deep ? deep.volume / 100 : 1;
// Convert MIDI note to frequency
const freq = 440 * Math.pow(2, (note + octShift - 69 + detuneC / 100) / 12);
// Use bassDeepSettings for slide/decay
const slide = bassDeepSettings.slide || 30; // 0-100, controls slide speed
const decay = (bassDeepSettings.decay || 400) / 1000; // Convert ms to seconds
const filterCut = bassDeepSettings.filter || 250; // Low-pass cutoff (Hz) - lower for classic 808
const res = bassDeepSettings.res || 3; // Resonance - lower for classic sound
// Main oscillator - Pure sine for clean sub foundation
const osc = ctx.createOscillator();
osc.type = 'sine';
// Classic 808 pitch slide (the "ratchet trick")
const doSlide = Math.random() < 0.4; // 40% chance for that classic feel
if (doSlide && slide > 0) {
// Start from lower pitch, slide up to target (classic 808 trick)
const slideFrom = freq * (0.7 + (slide / 100) * 0.2); // Slide amount affects start pitch
const slideTime = (slide / 100) * 0.08; // Faster slide = more "pop"
osc.frequency.setValueAtTime(slideFrom, time);
osc.frequency.exponentialRampToValueAtTime(freq, time + slideTime);
} else {
osc.frequency.setValueAtTime(freq, time);
}
// === THE BUZZ (Saturation/Distortion) ===
// Classic 808 buzz comes from overdriving the sine wave
const shaper = ctx.createWaveShaper();
const curve = new Float32Array(256);
// Asymmetric soft clipping for that classic buzzy character
for (let i = 0; i < 256; i++) {
const x = (i - 128) / 128;
// Asymmetric curve: more distortion on positive side (classic 808 character)
if (x > 0) {
curve[i] = Math.tanh(x * 4.5) * 0.9; // Slight compression on positive
} else {
curve[i] = Math.tanh(x * 3.0); // Less distortion on negative
}
}
shaper.curve = curve;
// Low-pass filter to tame the buzz and keep it in the low end
const lp = ctx.createBiquadFilter();
lp.type = 'lowpass';
lp.frequency.value = filterCut;
lp.Q.value = res;
// Gentle high-pass to prevent sub-woofer overload (30-40Hz)
const safetyHP = ctx.createBiquadFilter();
safetyHP.type = 'highpass';
safetyHP.frequency.value = 35;
safetyHP.Q.value = 0.7;
// === THE POP (Sharp Attack Envelope) ===
// Classic 808 has an INSTANT attack - that's the "pop"
const gain = ctx.createGain();
const finalVol = vol * volMult;
// INSTANT attack (the "pop") - no ramp, just BAM
gain.gain.setValueAtTime(0.0001, time);
gain.gain.setValueAtTime(finalVol, time + 0.0001); // Near-instant (0.1ms)
// Exponential decay (the "buzz ratchet" tail)
gain.gain.exponentialRampToValueAtTime(0.001, time + decay);
// Longer tail for sustained notes
if (decay > 0.25) {
gain.gain.exponentialRampToValueAtTime(0.0001, time + decay * 1.3);
}
// Routing: Osc -> Shaper (buzz) -> LP (tame) -> Safety HP -> Gain (pop envelope) -> Out
osc.connect(shaper);
shaper.connect(lp);
lp.connect(safetyHP);
safetyHP.connect(gain);
gain.connect(musicGain);
// Minimal FX (808 bass should be dry and punchy)
if (deep) {
const delaySend = deep.delaySend / 100;
if (delaySend > 0 && leadFxDelay) {
const ds = ctx.createGain();
ds.gain.value = delaySend * 0.1; // Very light delay
gain.connect(ds);
ds.connect(leadFxDelay);
}
}
osc.start(time);
osc.stop(time + Math.max(decay + 0.05, 0.4));
}
// === SQUAREPUSHER BASS EMULATION ===
// Harmonics + distortion + delay + filter chain
// Can be used as bass style or extended voice
function playSquarepusherBass(time, note = 36, vol = 0.008, options = {}) {
const ctx = initAudio();
// Get deep settings if applicable
const deep = getLeadSettingsForVoice('bass');
const octShift = deep ? deep.octave * 12 : 0;
const driftC = deep && deep.drift ? (Math.random() - 0.5) * 2 * deep.drift : 0;
const detuneC = (deep ? deep.detune : 0) + driftC;
const volMult = deep ? deep.volume / 100 : 1;
// Convert MIDI note to frequency
const freq = 440 * Math.pow(2, (note + octShift - 69 + detuneC / 100) / 12);
// Parameters
const distortionAmount = options.distortion || 50;
const delayTime = options.delayTime || 0.01;
const filterCutoff = options.filterCutoff || 8000;
const enableWah = options.enableWah || false;
const wahRate = options.wahRate || 5;
// Cap final volume to prevent excessive loudness - very subtle layer
const finalVol = Math.min(vol * volMult, 0.008); // Never exceed 0.8% - very quiet layer
// Harmonics oscillator (sawtooth base)
const harmOsc = ctx.createOscillator();
harmOsc.type = 'sawtooth';
harmOsc.frequency.value = freq;
// Harmonics gain (for envelope)
const harmGain = ctx.createGain();
harmGain.gain.value = 0;
// Distortion (waveshaper)
const waveShaper = ctx.createWaveShaper();
waveShaper.curve = makeSquarepusherDistortionCurve(distortionAmount);
// Filter (lowpass, sweepable for wah)
const filter = ctx.createBiquadFilter();
filter.type = 'lowpass';
filter.frequency.value = filterCutoff;
filter.Q.value = 1.0;
// Delay with feedback
const delay = ctx.createDelay(0.1);
delay.delayTime.value = delayTime;
const delayFeedback = ctx.createGain();
delayFeedback.gain.value = 0.2;
const delayWet = ctx.createGain();
delayWet.gain.value = 0.3;
const delayDry = ctx.createGain();
delayDry.gain.value = 0.7;
// Output gain
const outGain = ctx.createGain();
outGain.gain.value = finalVol;
// Envelope
const attackTime = 0.001;
const decayTime = 0.3;
harmGain.gain.setValueAtTime(0, time);
harmGain.gain.linearRampToValueAtTime(1.0, time + attackTime);
harmGain.gain.exponentialRampToValueAtTime(0.01, time + decayTime);
// Wah effect (optional filter sweep)
if (enableWah) {
const wahDuration = 0.5;
filter.frequency.setValueAtTime(filterCutoff, time);
filter.frequency.linearRampToValueAtTime(filterCutoff * 2, time + wahDuration * 0.5);
filter.frequency.linearRampToValueAtTime(filterCutoff, time + wahDuration);
}
// Connect chain: harmOsc → harmGain → waveShaper → filter → delay split → outGain
harmOsc.connect(harmGain);
harmGain.connect(waveShaper);
waveShaper.connect(filter);
// Delay: split dry/wet
filter.connect(delayDry);
filter.connect(delay);
delay.connect(delayFeedback);
delayFeedback.connect(delay); // Feedback loop
delay.connect(delayWet);
// Merge dry + wet
const delayMerge = ctx.createGain();
delayDry.connect(delayMerge);
delayWet.connect(delayMerge);
delayMerge.connect(outGain);
outGain.connect(musicGain);
// Start/stop
harmOsc.start(time);
harmOsc.stop(time + decayTime + 0.1);
}
// Helper: Custom distortion curve for Squarepusher bass
function makeSquarepusherDistortionCurve(amount) {
const k = amount;
const n_samples = 4096;
const curve = new Float32Array(n_samples);
const deg = Math.PI / 180;
for (let i = 0; i < n_samples; ++i) {
const x = (i * 2) / n_samples - 1;
curve[i] = ((2 + k) * x * 0.7) / (1 + k * Math.abs(x));
}
return curve;
}
// Helper: Trigger harmonic (fret-based) - also very quiet
function triggerSquarepusherHarmonic(time, fret, velocity = 0.4) {
const ctx = initAudio();
const baseFreq = 110; // A2
const ratios = [1.2599, 1.5874, 2, 2.5198]; // 3rd, 5th, 7th, 9th fret
const freq = baseFreq * ratios[fret % 4];
const osc = ctx.createOscillator();
osc.type = 'sawtooth';
osc.frequency.setValueAtTime(freq, time);
const gain = ctx.createGain();
// Much quieter harmonics - max 0.01 (1%)
const quietVelocity = Math.min(velocity * 0.25, 0.01);
gain.gain.setValueAtTime(0, time);
gain.gain.linearRampToValueAtTime(quietVelocity, time + 0.001);
gain.gain.exponentialRampToValueAtTime(0.001, time + 0.3);
// Apply same effects chain as main bass
const waveShaper = ctx.createWaveShaper();
waveShaper.curve = makeSquarepusherDistortionCurve(50);
const filter = ctx.createBiquadFilter();
filter.type = 'lowpass';
filter.frequency.value = 8000;
osc.connect(gain);
gain.connect(waveShaper);
waveShaper.connect(filter);
filter.connect(musicGain);
osc.start(time);
osc.stop(time + 0.3);
}
// === µ-ZIQ MELODY SYSTEM ===
// Granular synthesis + dual arpeggiators (Mike Paradinas style)
// Pure synthesis - no samples required
// Helper: Create noise grain buffer (20ms)
function createNoiseGrain(duration = 0.02) {
const ctx = initAudio();
const sampleRate = ctx.sampleRate;
const length = sampleRate * duration;
const buffer = ctx.createBuffer(1, length, sampleRate);
const data = buffer.getChannelData(0);
for (let i = 0; i < length; i++) {
data[i] = (Math.random() - 0.5) * 0.3;
}
return buffer;
}
// Helper: Play dual arpeggiator (offset arps for dissonance)
function playUZiqDualArp(time, baseNote, vol = 0.12, options = {}) {
const ctx = initAudio();
const baseFreq = 440 * Math.pow(2, (baseNote - 69) / 12);
const rate = options.rate || 0.125; // 1/8-note default
const weirdIntervals = [7, 12, 4, 9, 14, 2, -5, 3, 10, -2, 8, 1]; // Maj7, weird intervals
// Arp 1: Sawtooth, standard rate
const arp1Notes = weirdIntervals.slice(0, 6);
arp1Notes.forEach((interval, i) => {
const osc1 = ctx.createOscillator();
osc1.type = 'sawtooth';
osc1.frequency.value = baseFreq * Math.pow(2, interval / 12);
const g1 = ctx.createGain();
g1.gain.setValueAtTime(0, time + i * rate);
g1.gain.linearRampToValueAtTime(vol * 0.6, time + i * rate + 0.001);
g1.gain.exponentialRampToValueAtTime(0.01, time + i * rate + 0.08);
// Bandpass filter for µ-Ziq character
const filter1 = ctx.createBiquadFilter();
filter1.type = 'bandpass';
filter1.frequency.setValueAtTime(400, time + i * rate);
filter1.frequency.linearRampToValueAtTime(4000, time + i * rate + 0.03);
filter1.Q.value = 8;
osc1.connect(g1);
g1.connect(filter1);
filter1.connect(musicGain);
osc1.start(time + i * rate);
osc1.stop(time + i * rate + 0.08);
});
// Arp 2: Triangle, 1.3x rate offset (dissonant layer)
const arp2Notes = weirdIntervals.slice(6, 12);
arp2Notes.forEach((interval, i) => {
const osc2 = ctx.createOscillator();
osc2.type = 'triangle';
osc2.frequency.value = baseFreq * Math.pow(2, interval / 12);
const g2 = ctx.createGain();
const offsetTime = time + (i * rate * 1.3); // 1.3x offset
g2.gain.setValueAtTime(0, offsetTime);
g2.gain.linearRampToValueAtTime(vol * 0.4, offsetTime + 0.001);
g2.gain.exponentialRampToValueAtTime(0.01, offsetTime + 0.06);
// Bandpass filter for µ-Ziq character
const filter2 = ctx.createBiquadFilter();
filter2.type = 'bandpass';
filter2.frequency.setValueAtTime(400, offsetTime);
filter2.frequency.linearRampToValueAtTime(4000, offsetTime + 0.03);
filter2.Q.value = 8;
osc2.connect(g2);
g2.connect(filter2);
filter2.connect(musicGain);
osc2.start(offsetTime);
osc2.stop(offsetTime + 0.06);
});
}
// Main µ-Ziq melody function: Granular + dual arp
function playUZiqMelody(time, note = 60, vol = 0.12, options = {}) {
const ctx = initAudio();
const baseFreq = 440 * Math.pow(2, (note - 69) / 12);
const grainSize = options.grainSize || 0.02; // 20ms
const grainRate = options.grainRate || 64; // 64 grains/sec
const grainInterval = 1.0 / grainRate; // ~15.6ms between grains
const numGrains = Math.floor((options.duration || 0.3) * grainRate); // Grains for duration
// Granular cloud: Mix of noise + square osc grains
for (let i = 0; i < numGrains; i++) {
const grainTime = time + (i * grainInterval);
// Noise grain
const noiseGrain = ctx.createBufferSource();
noiseGrain.buffer = createNoiseGrain(grainSize);
// Square osc grain (detuned ±10%)
const oscGrain = ctx.createOscillator();
oscGrain.type = 'square';
const detune = 0.9 + (Math.random() * 0.2); // ±10% detune
oscGrain.frequency.value = baseFreq * detune;
// Grain envelope - increased for more audible "wikky wikky" character
const grainGain = ctx.createGain();
grainGain.gain.setValueAtTime(0, grainTime);
grainGain.gain.linearRampToValueAtTime(vol * 0.5, grainTime + 0.001); // Increased from 0.3 to 0.5
grainGain.gain.exponentialRampToValueAtTime(0.01, grainTime + grainSize);
// Bandpass filter sweep per grain (400→4000Hz)
const filter = ctx.createBiquadFilter();
filter.type = 'bandpass';
filter.frequency.setValueAtTime(400, grainTime);
filter.frequency.linearRampToValueAtTime(4000, grainTime + grainSize);
filter.Q.value = 8;
// Mix noise + osc
const mixer = ctx.createGain();
noiseGrain.connect(mixer);
oscGrain.connect(mixer);
mixer.connect(grainGain);
grainGain.connect(filter);
filter.connect(musicGain);
noiseGrain.start(grainTime);
noiseGrain.stop(grainTime + grainSize);
oscGrain.start(grainTime);
oscGrain.stop(grainTime + grainSize);
}
// Dual arpeggiator (triggered with granular) - increased volume for more presence
if (options.enableArp !== false) {
playUZiqDualArp(time, note, vol * 1.0, { rate: 0.125 }); // Increased from 0.8 to 1.0
}
}
// === FM LEAD STAB (XFM-style from $1010) ===
// Short gated FM stabs with vibrato and filter energy
// isMotif: quieter, longer, more filtered - the beautiful ambient stuff
function playFMPad(time, note = 30, vol = 0.15, isMotif = false, overrides = {}) {
const ctx = initAudio();
// Get deep settings if applicable
const deep = getLeadSettingsForVoice('fmPad');
const octShift = deep ? deep.octave * 12 : 0;
const driftC = deep && deep.drift ? (Math.random() - 0.5) * 2 * deep.drift : 0;
const detuneC = (deep ? deep.detune : 0) + driftC;
const volMult = deep ? deep.volume / 100 : 1;
const baseFreq = 440 * Math.pow(2, (note + octShift - 69 + detuneC / 100) / 12);
// === MR. FINGERS / WAYNE GARDNER STYLE PADS ===
// Multiple detuned oscillators (4-6) for warm, glassy texture
// Slick, short pads with quick ADSR
// Duration: Short, slick pads (not long sustained)
const baseGate = isMotif ? (0.3 + Math.random() * 0.2) : (0.2 + Math.random() * 0.15); // 0.2-0.35s for normal, 0.3-0.5s for motifs
const gate = overrides.decay ? overrides.decay : (deep ? (deep.attack + deep.decay + deep.release) / 1000 + 0.05 : baseGate);
const actualVol = (isMotif ? (vol * 0.70) : vol) * volMult;
// Choose pad style: 60% glassy (sine-based), 40% warm (saw-based)
const isGlassy = Math.random() < 0.6;
const oscType = isGlassy ? 'sine' : 'sawtooth';
// Multiple detuned oscillators (4-6) for Jupiter-6 style warmth
const oscCount = 4 + Math.floor(Math.random() * 3); // 4-6 oscillators
const oscs = [];
const oscGains = [];
const mixer = ctx.createGain();
// Create detuned oscillator bank
for (let i = 0; i < oscCount; i++) {
const osc = ctx.createOscillator();
osc.type = oscType;
// Detune: ±0.5-2 cents per oscillator (subtle, warm detune)
const detuneCents = (i - (oscCount - 1) / 2) * (0.5 + Math.random() * 1.5);
const detuneRatio = Math.pow(2, detuneCents / 1200);
osc.frequency.value = baseFreq * detuneRatio;
// Individual gain per oscillator (slight volume variation)
const oscGain = ctx.createGain();
const oscVol = 0.8 + (Math.random() * 0.4); // 0.8-1.2 per oscillator
oscGain.gain.value = oscVol;
osc.connect(oscGain);
oscGain.connect(mixer);
oscs.push(osc);
oscGains.push(oscGain);
}
// Vibrato LFO (slow, subtle) - applies to all oscillators
const vibRate = deep ? deep.vibRate : (0.3 + Math.random() * 0.4); // 0.3-0.7 Hz (slow, breath-like)
const vibDepth = deep ? deep.vibDepth / 100 : 0.003; // Subtle vibrato
const lfo = ctx.createOscillator();
const lfoGain = ctx.createGain();
lfo.type = 'sine';
lfo.frequency.value = vibRate;
lfoGain.gain.value = baseFreq * vibDepth;
lfo.connect(lfoGain);
// Apply vibrato to all oscillators
oscs.forEach(osc => {
lfoGain.connect(osc.frequency);
});
// === MOTIF DEFAULTS ===
// Motif pads default: ATK 70ms, RESO 18, CUTOFF 3kHz (only if no overrides)
const motifDefaults = isMotif && !overrides.attack && !overrides.reso && !overrides.cutoff ? {
attack: 70,
reso: 18,
cutoff: 3000
} : null;
// === LOW-PASS FILTER WITH BREATH SWEEP ===
// Mr. Fingers style: slow filter sweeps for "breath"
const filter = ctx.createBiquadFilter();
filter.type = 'lowpass'; // Always lowpass for warm pads
// Priority: overrides > motif defaults (when isMotif) > padDeepSettings > deep > default
const padFilterCut = padDeepSettings.filter || 2000;
const padReso = padDeepSettings.reso || 5;
// Filter sweep: start higher, breathe down, then back up
// Motif defaults (3kHz) take priority over padDeepSettings when isMotif=true
const filterStart = overrides.cutoff ? overrides.cutoff : (motifDefaults ? motifDefaults.cutoff : (padDeepSettings.filter ? padFilterCut : (deep ? deep.filterCut : (1500 + Math.random() * 1000))));
const filterLow = filterStart * (0.3 + Math.random() * 0.2); // 30-50% of start (breath down)
const filterEnd = filterStart * (0.6 + Math.random() * 0.3); // 60-90% of start (breath back up)
filter.frequency.setValueAtTime(filterStart, time);
// Slow sweep down (breath out) - over first 40% of duration
filter.frequency.exponentialRampToValueAtTime(filterLow, time + gate * 0.4);
// Slow sweep back up (breath in) - over next 40% of duration
filter.frequency.exponentialRampToValueAtTime(filterEnd, time + gate * 0.8);
// Gentle fade down at end
filter.frequency.exponentialRampToValueAtTime(filterLow * 0.8, time + gate);
// Priority: overrides > motif defaults (when isMotif) > padDeepSettings > deep > default
const qVal = overrides.reso ? overrides.reso : (motifDefaults ? motifDefaults.reso : (padDeepSettings.reso ? padReso : (deep ? deep.filterRes : (1.5 + Math.random() * 1.5)))); // Lower Q for warmth
filter.Q.value = safariSafe('maxResonance', qVal);
// === CHORUS/PHASER FOR SWEEPING WASHES (Wayne Gardner style) ===
// LFO-modulated delay for phaser-like sweeping
const chorusDelay = ctx.createDelay(0.02); // Max 20ms delay
const chorusLfo = ctx.createOscillator();
const chorusLfoGain = ctx.createGain();
const chorusFeedback = ctx.createGain();
const chorusWet = ctx.createGain();
const chorusDry = ctx.createGain();
chorusLfo.type = 'sine';
chorusLfo.frequency.value = 0.15 + Math.random() * 0.25; // 0.15-0.4 Hz (slow sweep)
chorusLfoGain.gain.value = 0.008; // ±8ms modulation
chorusFeedback.gain.value = 0.15; // Subtle feedback
chorusWet.gain.value = 0.3; // 30% wet
chorusDry.gain.value = 0.7; // 70% dry
chorusLfo.connect(chorusLfoGain);
chorusLfoGain.connect(chorusDelay.delayTime);
// Chorus routing: mixer → filter → split (dry/wet) → merge
mixer.connect(filter);
filter.connect(chorusDry);
filter.connect(chorusDelay);
chorusDelay.connect(chorusFeedback);
chorusFeedback.connect(chorusDelay); // Feedback loop
chorusDelay.connect(chorusWet);
const chorusMerge = ctx.createGain();
chorusDry.connect(chorusMerge);
chorusWet.connect(chorusMerge);
// === SPARING, SLICK ADSR (Mr. Fingers style) ===
// Subtle, quick attack/release for slick pads
// Motif defaults: ATK 70ms (if no overrides) - MUST come before padDeepSettings
const atkMs = overrides.attack ? overrides.attack * 1000 : (motifDefaults ? motifDefaults.attack : (padDeepSettings.attack ? padDeepSettings.attack : (deep ? deep.attack : (isMotif ? 70 : (150 + Math.random() * 350))))); // 150-500ms normal, motif default 70ms
const decMs = deep ? deep.decay : (isMotif ? 400 : (200 + Math.random() * 400)); // 200-600ms (was 0.8-2s)
const susLvl = deep ? deep.sustain / 100 : (0.5 + Math.random() * 0.2); // 50-70% sustain (was 60-80%)
const relMs = padDeepSettings.release ? padDeepSettings.release : (deep ? deep.release : (isMotif ? 1200 : (800 + Math.random() * 1200))); // 0.8-2s (was 2-5s)
const masterGain = ctx.createGain();
masterGain.gain.setValueAtTime(0.0001, time);
// Quick, slick attack (150-500ms)
masterGain.gain.linearRampToValueAtTime(actualVol, time + atkMs / 1000);
// Fast decay to sustain
masterGain.gain.setTargetAtTime(actualVol * susLvl, time + atkMs / 1000, decMs / 3000);
// Hold at sustain
masterGain.gain.setValueAtTime(actualVol * susLvl, time + gate - (relMs / 1000));
// Slick release (0.8-2s)
masterGain.gain.exponentialRampToValueAtTime(0.0001, time + gate);
// Connect: oscs → mixer → filter → chorus → masterGain
chorusMerge.connect(masterGain);
// === STEREO WIDTH (spread voicings) ===
// Mr. Fingers style: wide stereo spread for hypnotic layers
let finalOutput = masterGain;
if (deep && deep.stereoWidth > 0) {
const width = deep.stereoWidth / 100;
const splitter = ctx.createChannelSplitter(2);
const merger = ctx.createChannelMerger(2);
const leftDelay = ctx.createDelay(0.05);
const rightDelay = ctx.createDelay(0.05);
leftDelay.delayTime.value = 0.001 * width;
rightDelay.delayTime.value = 0.002 * width;
const stereoGain = ctx.createGain();
stereoGain.gain.value = 1;
masterGain.connect(splitter);
splitter.connect(leftDelay, 0);
splitter.connect(rightDelay, 0);
leftDelay.connect(merger, 0, 0);
rightDelay.connect(merger, 0, 1);
merger.connect(stereoGain);
finalOutput = stereoGain;
} else {
// Default: subtle stereo spread even without deep settings
const splitter = ctx.createChannelSplitter(2);
const merger = ctx.createChannelMerger(2);
const leftDelay = ctx.createDelay(0.01);
const rightDelay = ctx.createDelay(0.01);
leftDelay.delayTime.value = 0.0005;
rightDelay.delayTime.value = 0.001;
masterGain.connect(splitter);
splitter.connect(leftDelay, 0);
splitter.connect(rightDelay, 0);
leftDelay.connect(merger, 0, 0);
rightDelay.connect(merger, 0, 1);
finalOutput = merger;
}
// Bit crush effect (Safari-safe AudioWorklet version)
if (deep && deep.bitCrush > 0) {
const crushAmount = safariSafe('maxBitCrush', deep.bitCrush) / 100;
const bits = Math.max(2, Math.floor(16 - crushAmount * 14));
const srReduction = deep.sampleRate > 0 ? Math.max(1, Math.floor(1 + (deep.sampleRate / 100) * 16)) : 1;
const crusher = createBitCrusher(ctx, bits, srReduction);
const tempGain = ctx.createGain();
tempGain.gain.value = 1;
finalOutput.connect(crusher);
crusher.connect(tempGain);
finalOutput = tempGain;
}
finalOutput.connect(musicGain);
// FX sends
if (deep) {
const delaySend = deep.delaySend / 100;
const reverbSend = deep.reverbSend / 100;
if (delaySend > 0 && leadFxDelay) {
const ds = ctx.createGain(); ds.gain.value = delaySend * 0.5;
finalOutput.connect(ds); ds.connect(leadFxDelay);
leadFxDelayWet.gain.value = Math.max(leadFxDelayWet.gain.value, 0.6);
}
if (reverbSend > 0 && leadFxReverb) {
const rs = ctx.createGain(); rs.gain.value = reverbSend * 0.4;
finalOutput.connect(rs); rs.connect(leadFxReverb);
leadFxReverbWet.gain.value = Math.max(leadFxReverbWet.gain.value, 0.5);
}
}
// Start/stop all oscillators and LFOs
oscs.forEach(osc => {
osc.start(time);
osc.stop(time + gate + 0.2);
});
lfo.start(time);
lfo.stop(time + gate + 0.2);
chorusLfo.start(time);
chorusLfo.stop(time + gate + 0.2);
}
function playFMPitchWhammyLead(time, note = 60, vol = 0.130, overrides = {}) {
const ctx = initAudio();
// Get deep settings if applicable
const deep = getLeadSettingsForVoice('fmWhammy');
const octShift = deep ? deep.octave * 12 : 0;
const driftC = deep && deep.drift ? (Math.random() - 0.5) * 2 * deep.drift : 0;
const detuneC = (deep ? deep.detune : 0) + driftC;
const volMult = deep ? deep.volume / 100 : 1;
const baseFreq = 440 * Math.pow(2, (note + octShift - 69 + detuneC / 100) / 12);
const baseDuration = 0.28 + Math.random() * 0.16;
const dDecay = overrides.decay ? overrides.decay : (deep ? (deep.attack + deep.decay + deep.release) / 1000 : baseDuration);
const duration = dDecay + 0.05;
const carrier = ctx.createOscillator();
const mod = ctx.createOscillator();
const modGain = ctx.createGain();
const filter = ctx.createBiquadFilter();
const g = ctx.createGain();
carrier.type = 'sine';
mod.type = 'sine';
const bendUp = 1.06 + Math.random() * 0.06;
const bendDown = 0.98 + Math.random() * 0.02;
carrier.frequency.setValueAtTime(baseFreq * bendUp, time);
carrier.frequency.exponentialRampToValueAtTime(baseFreq * bendDown, time + 0.07);
const doRiff = Math.random() < 0.45;
if (doRiff) {
const up = baseFreq * Math.pow(2, 7 / 12);
carrier.frequency.exponentialRampToValueAtTime(up, time + 0.12);
carrier.frequency.exponentialRampToValueAtTime(baseFreq, time + duration);
} else {
carrier.frequency.exponentialRampToValueAtTime(baseFreq, time + duration);
}
mod.frequency.setValueAtTime(2.2 + Math.random() * 1.8, time);
modGain.gain.setValueAtTime(baseFreq * (0.55 + Math.random() * 0.35), time);
mod.connect(modGain);
modGain.connect(carrier.frequency);
// Vibrato - use deep settings
const vibRate = deep ? deep.vibRate : 5.0 + Math.random() * 2.5;
const vibDepth = deep ? deep.vibDepth / 100 : 0.004;
const vib = ctx.createOscillator();
const vibGain = ctx.createGain();
vib.type = 'sine';
vib.frequency.setValueAtTime(vibRate, time);
vibGain.gain.setValueAtTime(baseFreq * vibDepth * 0.2, time);
vib.connect(vibGain);
vibGain.connect(carrier.frequency);
// Filter with occasional sweep - use deep settings
filter.type = deep ? deep.filterType : 'lowpass';
const baseFilterCut = overrides.cutoff ? overrides.cutoff : (deep ? deep.filterCut : 1200 + Math.random() * 800);
const baseQ = overrides.reso ? overrides.reso : (deep ? safariSafe('maxResonance', deep.filterRes) : 2 + Math.random() * 3);
// Dynamic filter: longer notes always sweep, short notes 25% chance
const isLongNote = duration > 0.3;
const doFilterSweep = isLongNote ? Math.random() < 0.55 : Math.random() < 0.25;
if (doFilterSweep && !deep) {
const sweepQ = 5 + Math.random() * 7;
const startCut = 600 + Math.random() * 400;
const peakCut = Math.min(6000, baseFreq * 4 + Math.random() * 1500);
filter.Q.setValueAtTime(safariSafe('maxResonance', sweepQ), time);
filter.frequency.setValueAtTime(startCut, time);
filter.frequency.exponentialRampToValueAtTime(peakCut, time + duration * 0.25);
filter.frequency.exponentialRampToValueAtTime(startCut * 1.2, time + duration * 0.85);
} else {
filter.frequency.setValueAtTime(baseFilterCut, time);
filter.Q.setValueAtTime(baseQ, time);
}
// Envelope - use deep ADSR
const finalVol = vol * volMult;
const atkMs = deep ? deep.attack : 10;
g.gain.setValueAtTime(0.0001, time);
g.gain.exponentialRampToValueAtTime(finalVol, time + atkMs / 1000);
g.gain.exponentialRampToValueAtTime(0.0001, time + duration + 0.16);
carrier.connect(filter);
filter.connect(g);
g.connect(musicGain);
// FX sends
if (deep) {
const delaySend = deep.delaySend / 100;
const reverbSend = deep.reverbSend / 100;
if (delaySend > 0 && leadFxDelay) {
const ds = ctx.createGain(); ds.gain.value = delaySend * 0.5;
g.connect(ds); ds.connect(leadFxDelay);
leadFxDelayWet.gain.value = Math.max(leadFxDelayWet.gain.value, 0.6);
}
if (reverbSend > 0 && leadFxReverb) {
const rs = ctx.createGain(); rs.gain.value = reverbSend * 0.4;
g.connect(rs); rs.connect(leadFxReverb);
leadFxReverbWet.gain.value = Math.max(leadFxReverbWet.gain.value, 0.5);
}
}
carrier.start(time);
mod.start(time);
vib.start(time);
carrier.stop(time + duration + 0.20);
mod.stop(time + duration + 0.20);
vib.stop(time + duration + 0.20);
}
function playFMBrassStab(time, note = 60, vol = 0.012, overrides = {}) {
const ctx = initAudio();
// Get deep settings if applicable
const deep = getLeadSettingsForVoice('fmBrass');
const octShift = deep ? deep.octave * 12 : 0;
const driftC = deep && deep.drift ? (Math.random() - 0.5) * 2 * deep.drift : 0;
const detuneC = (deep ? deep.detune : 0) + driftC;
const volMult = deep ? deep.volume / 100 : 1;
const baseFreq = 440 * Math.pow(2, (note + octShift - 69 + detuneC / 100) / 12);
// Stabs should be SHORT and punchy - not long melodic notes
// Varied durations: 80-180ms (short stabs) with occasional longer ones (200-300ms)
const isLongStab = Math.random() < 0.15; // 15% chance for longer stab
const baseDuration = isLongStab
? (0.20 + Math.random() * 0.10) // 200-300ms for occasional longer ones
: (0.08 + Math.random() * 0.10); // 80-180ms for typical short stabs
const dDecay = overrides.decay ? overrides.decay : (deep ? (deep.attack + deep.decay + deep.release) / 1000 : baseDuration);
const duration = Math.min(dDecay, 0.30); // Cap at 300ms max (stabs shouldn't be too long)
const car = ctx.createOscillator();
const mod = ctx.createOscillator();
const modGain = ctx.createGain();
const filter = ctx.createBiquadFilter();
const g = ctx.createGain();
// Vibrato for sax-like expression
const vibrato = ctx.createOscillator();
const vibGain = ctx.createGain();
// Sine carrier (warmer than sawtooth) with subtle harmonics from FM
// Sine carrier, but Sawtooth modulator for that "Rave/Orch Hit" bite
car.type = 'sine';
mod.type = 'sawtooth'; // changed from sine for brightness
vibrato.type = 'sine';
// Unison oscillator for "Section" feel (thickener)
const car2 = ctx.createOscillator();
car2.type = 'triangle';
const detuneAmt = 6 + Math.random() * 4; // cents
// ... Pitch logic remains ...
const isLongNote = duration > 0.35;
const doPitchBend = !deep && (isLongNote ? Math.random() < 0.40 : Math.random() < 0.15);
if (doPitchBend) {
// ... (keep scoop logic)
const startFreq = baseFreq * (0.94 + Math.random() * 0.04);
car.frequency.setValueAtTime(startFreq, time);
car.frequency.exponentialRampToValueAtTime(baseFreq, time + duration * 0.2);
car2.frequency.setValueAtTime(startFreq * Math.pow(2, detuneAmt / 1200), time);
car2.frequency.exponentialRampToValueAtTime(baseFreq * Math.pow(2, detuneAmt / 1200), time + duration * 0.2);
mod.frequency.setValueAtTime(startFreq, time); // 1:1 ratio for brass
mod.frequency.exponentialRampToValueAtTime(baseFreq, time + duration * 0.2);
} else {
car.frequency.setValueAtTime(baseFreq, time);
car2.frequency.setValueAtTime(baseFreq * Math.pow(2, detuneAmt / 1200), time);
mod.frequency.setValueAtTime(baseFreq, time); // 1:1 ratio
}
// FM Routing
// High index for "Blat" sound
modGain.gain.setValueAtTime(baseFreq * 3.5, time); // Brighter
modGain.gain.exponentialRampToValueAtTime(baseFreq * 0.5, time + 0.08); // Quick drop
mod.connect(modGain);
modGain.connect(car.frequency);
modGain.connect(car2.frequency); // Modulate both
car.connect(filter);
car2.connect(filter); // Mix in unison
// Vibrato
vibGain.gain.value = 4;
vibrato.frequency.value = 5.5;
vibrato.connect(vibGain);
vibGain.connect(car.frequency);
// Don't vibrate car2 for chorus effect friction (optional, but let's leave it separate)
vibGain.gain.linearRampToValueAtTime(baseFreq * 0.012, time + duration);
vibrato.connect(vibGain);
vibGain.connect(car.frequency);
// Filter with occasional breath-like sweep
filter.type = deep ? deep.filterType : 'lowpass';
const baseFilterCut = overrides.cutoff ? overrides.cutoff : (deep ? deep.filterCut : 1800 + baseFreq * 0.5);
const baseQ = overrides.reso ? overrides.reso : (deep ? safariSafe('maxResonance', deep.filterRes) : 0.8);
// Brass gets filter sweep on longer notes (like breath opening)
const doFilterSweep = isLongNote ? Math.random() < 0.50 : Math.random() < 0.18;
if (doFilterSweep && !deep) {
const sweepQ = 2 + Math.random() * 4; // Gentler Q for brass
const startCut = 500 + Math.random() * 400;
const peakCut = Math.min(4000, baseFreq * 3 + Math.random() * 1000);
filter.Q.setValueAtTime(safariSafe('maxResonance', sweepQ), time);
filter.frequency.setValueAtTime(startCut, time);
// Slower open for breath-like feel
filter.frequency.exponentialRampToValueAtTime(peakCut, time + duration * 0.4);
filter.frequency.exponentialRampToValueAtTime(peakCut * 0.6, time + duration * 0.9);
} else {
filter.frequency.setValueAtTime(baseFilterCut, time);
filter.Q.setValueAtTime(baseQ, time);
}
// === REVERSED ENVELOPE (always) ===
// FM Brass should always start loud and decay (reverse attack)
// Varied ADSR per note to avoid repetitive "ahhhh ahhhh"
const finalVol = vol * volMult;
// Vary ADSR parameters for each stab (creates variety)
const attackVariation = deep ? 0 : (Math.random() * 0.015); // 0-15ms variation
const decayVariation = deep ? 0 : (Math.random() * 0.08); // 0-80ms variation
const sustainVariation = deep ? 0 : (0.3 + Math.random() * 0.4); // 0.3-0.7 sustain level
const releaseVariation = deep ? 0 : (Math.random() * 0.15); // 0-150ms variation
// Use deep settings if available, otherwise varied defaults
// For short stabs: very fast everything, for longer ones: more time
const isShortStab = duration < 0.15;
const attackTime = deep ? (deep.attack / 1000) : (isShortStab ? (0.001 + attackVariation * 0.5) : (0.002 + attackVariation)); // 1-9ms for short, 2-17ms for long
const decayTime = deep ? (deep.decay / 1000) : (isShortStab ? (0.02 + decayVariation * 0.5) : (0.05 + decayVariation)); // 20-60ms for short, 50-130ms for long
const sustainLevel = deep ? (deep.sustain / 100) : (isShortStab ? 0.2 : sustainVariation); // 20% for short stabs, 30-70% for longer
const releaseTime = deep ? (deep.release / 1000) : (isShortStab ? (0.03 + releaseVariation * 0.5) : (0.08 + releaseVariation)); // 30-80ms for short, 80-230ms for long
// REVERSED: Start at full volume, decay to sustain, then release
g.gain.setValueAtTime(finalVol, time); // Start loud (reversed attack)
g.gain.exponentialRampToValueAtTime(finalVol * sustainLevel, time + attackTime + decayTime); // Decay to sustain
// For short stabs, skip sustain hold - go straight to release
if (isShortStab) {
g.gain.exponentialRampToValueAtTime(0.0001, time + duration); // Quick release for short stabs
} else {
g.gain.setValueAtTime(finalVol * sustainLevel, time + duration - releaseTime); // Hold sustain for longer ones
g.gain.exponentialRampToValueAtTime(0.0001, time + duration); // Release
}
car.connect(filter);
filter.connect(g);
g.connect(musicGain);
// FX sends
if (deep) {
const delaySend = deep.delaySend / 100;
const reverbSend = deep.reverbSend / 100;
if (delaySend > 0 && leadFxDelay) {
const ds = ctx.createGain(); ds.gain.value = delaySend * 0.5;
g.connect(ds); ds.connect(leadFxDelay);
leadFxDelayWet.gain.value = Math.max(leadFxDelayWet.gain.value, 0.6);
}
if (reverbSend > 0 && leadFxReverb) {
const rs = ctx.createGain(); rs.gain.value = reverbSend * 0.4;
g.connect(rs); rs.connect(leadFxReverb);
leadFxReverbWet.gain.value = Math.max(leadFxReverbWet.gain.value, 0.5);
}
}
car.start(time);
mod.start(time);
vibrato.start(time);
car.stop(time + duration + 0.15);
mod.stop(time + duration + 0.15);
vibrato.stop(time + duration + 0.15);
}
// === CHIP LEAD (SID-style PWM with arps) ===
// Variable pulse width oscillator with optional arpeggio
function playChipLead(time, note = 60, vol = 0.36, isMotif = false, overrides = {}) {
const ctx = initAudio();
// Get deep settings if applicable
const deep = getLeadSettingsForVoice('chipLead');
const octShift = deep ? deep.octave * 12 : 0;
const driftC = deep && deep.drift ? (Math.random() - 0.5) * 2 * deep.drift : 0;
const detuneC = (deep ? deep.detune : 0) + driftC;
const volMult = deep ? deep.volume / 100 : 1;
const freq = 440 * Math.pow(2, (note + octShift - 69 + detuneC / 100) / 12);
// Shorter duration for motifs - snappy and precise
const baseDuration = isMotif ? (0.08 + Math.random() * 0.06) : (0.2 + Math.random() * 0.15);
// Use override decay or deep setting or base
const dDecay = overrides.decay ? overrides.decay : (deep ? (deep.attack + deep.decay + deep.release) / 1000 : baseDuration);
const duration = dDecay + 0.05;
// PWM via PeriodicWave - variable duty cycle
const pulseWidth = 0.1 + Math.random() * 0.4; // 10-50% duty
function createPWMOsc(pw) {
const osc = ctx.createOscillator();
const real = new Float32Array(32);
const imag = new Float32Array(32);
real[0] = 0; imag[0] = 0;
for (let n = 1; n < 32; n++) {
imag[n] = (2 / (n * Math.PI)) * Math.sin(n * Math.PI * pw);
}
const wave = ctx.createPeriodicWave(real, imag, { disableNormalization: false });
osc.setPeriodicWave(wave);
return osc;
}
const osc1 = createPWMOsc(pulseWidth);
// PWM Sweep (LFO modulation of pulse width)
if (overrides.pwmSweep) {
// We can't easily modulate a PeriodicWave node's shape in real-time without AudioWorklet or swapping buffers.
// For a simple 'sweep', we can just use 2 oscillators with slightly different static pulse widths and detune them,
// OR better: use a simple Square wave and standard detuning to simulate the phatness, since Web Audio doesn't have native PWM.
// BUT, since we have createPWMOsc, let's just stick to the static PW for now unless we rebuild the graph.
// ALTERNATIVE: Use the standard "Sawtooth + Inverted Sawtooth + Phase Offset" trick for PWM.
// For now, let's just apply a slight detune rub to simulate motion if pwmSweep is requested.
osc1.frequency.setValueAtTime(freq, time);
osc1.frequency.linearRampToValueAtTime(freq * 1.01, time + duration);
} else {
osc1.frequency.setValueAtTime(freq, time);
}
// Slide / Portamento
if (overrides.slideAmount) {
osc1.frequency.setValueAtTime(freq * Math.pow(2, -overrides.slideAmount / 12), time);
osc1.frequency.linearRampToValueAtTime(freq, time + (overrides.slideDuration || 0.1));
} else {
osc1.frequency.setValueAtTime(freq, time);
}
const osc2 = createPWMOsc(pulseWidth + 0.1); // Slight offset for thickness
osc1.frequency.value = freq;
osc2.frequency.value = freq * 1.003; // Slight detune
// Arp mode - higher chance for motifs (70%), normal 50%
const doArp = Math.random() < (isMotif ? 0.70 : 0.50);
const arpType = Math.floor(Math.random() * 3); // 0=major, 1=minor, 2=octave
if (doArp) {
const stepTime = 0.05;
if (arpType === 0) { // Major arp
osc1.frequency.setValueAtTime(freq, time);
osc1.frequency.setValueAtTime(freq * 1.26, time + stepTime);
osc1.frequency.setValueAtTime(freq * 1.5, time + stepTime * 2);
osc1.frequency.setValueAtTime(freq, time + stepTime * 3);
} else if (arpType === 1) { // Minor arp
osc1.frequency.setValueAtTime(freq, time);
osc1.frequency.setValueAtTime(freq * 1.19, time + stepTime);
osc1.frequency.setValueAtTime(freq * 1.5, time + stepTime * 2);
osc1.frequency.setValueAtTime(freq, time + stepTime * 3);
} else { // Octave arp
osc1.frequency.setValueAtTime(freq, time);
osc1.frequency.setValueAtTime(freq * 2, time + stepTime);
osc1.frequency.setValueAtTime(freq, time + stepTime * 2);
osc1.frequency.setValueAtTime(freq * 2, time + stepTime * 3);
}
// Match osc2
osc2.frequency.setValueAtTime(freq * 1.003, time);
osc2.frequency.setValueAtTime(freq * 1.26 * 1.003, time + stepTime);
osc2.frequency.setValueAtTime(freq * 1.5 * 1.003, time + stepTime * 2);
osc2.frequency.setValueAtTime(freq * 1.003, time + stepTime * 3);
}
// Vibrato LFO - use deep settings
const vibRate = deep ? deep.vibRate : 5 + Math.random() * 3;
const vibDepth = deep ? deep.vibDepth / 100 : 0.015;
const vibDelayMs = deep ? deep.vibDelay : 80;
const lfo = ctx.createOscillator();
const lfoGain = ctx.createGain();
lfo.type = 'sine';
lfo.frequency.value = vibRate;
lfoGain.gain.setValueAtTime(0, time);
lfoGain.gain.linearRampToValueAtTime(freq * vibDepth * 0.1, time + vibDelayMs / 1000);
lfo.connect(lfoGain);
lfoGain.connect(osc1.frequency);
lfoGain.connect(osc2.frequency);
// Filter - use deep settings or overrides
const filter = ctx.createBiquadFilter();
filter.type = deep ? deep.filterType : 'lowpass';
const filterStart = overrides.cutoff ? overrides.cutoff : (deep ? deep.filterCut : (isMotif ? (freq * 8) : (freq * 6)));
const filterEnd = deep ? deep.filterCut * 0.3 : (isMotif ? (freq * 2) : (freq * 1.5));
filter.frequency.setValueAtTime(filterStart, time);
filter.frequency.exponentialRampToValueAtTime(Math.max(20, filterEnd), time + duration);
const qVal = overrides.reso ? overrides.reso : (deep ? deep.filterRes : (isMotif ? (6 + Math.random() * 8) : (3 + Math.random() * 5)));
filter.Q.value = safariSafe('maxResonance', qVal);
// LFO → Filter modulation (wobble/wah)
let filterLfo;
if (deep && deep.lfoFilter > 0) {
filterLfo = ctx.createOscillator();
const filterLfoGain = ctx.createGain();
filterLfo.type = 'sine';
filterLfo.frequency.value = deep.lfoRate || 4;
filterLfoGain.gain.value = filterStart * (deep.lfoFilter / 100) * 0.5;
filterLfo.connect(filterLfoGain);
filterLfoGain.connect(filter.frequency);
filterLfo.start(time);
filterLfo.stop(time + duration + 0.05);
}
// Envelope - use deep ADSR
const finalVol = vol * volMult;
const atkMs = deep ? deep.attack : 8;
const decMs = deep ? deep.decay : 100;
const susLvl = deep ? deep.sustain / 100 : 0.7;
const gain = ctx.createGain();
gain.gain.setValueAtTime(0.0001, time);
gain.gain.exponentialRampToValueAtTime(finalVol, time + atkMs / 1000);
gain.gain.exponentialRampToValueAtTime(Math.max(0.0001, finalVol * susLvl), time + (atkMs + decMs) / 1000);
gain.gain.exponentialRampToValueAtTime(0.0001, time + duration);
// Mix both oscillators
const mixer = ctx.createGain();
mixer.gain.value = 0.5;
osc1.connect(mixer);
osc2.connect(mixer);
mixer.connect(filter);
filter.connect(gain);
// Bit crush effect (Safari-safe AudioWorklet version)
let finalOutput = gain;
if (deep && deep.bitCrush > 0) {
const crushAmount = safariSafe('maxBitCrush', deep.bitCrush) / 100;
const bits = Math.max(2, Math.floor(16 - crushAmount * 14));
const srReduction = deep.sampleRate > 0 ? Math.max(1, Math.floor(1 + (deep.sampleRate / 100) * 16)) : 1;
const crusher = createBitCrusher(ctx, bits, srReduction);
const tempGain = ctx.createGain();
tempGain.gain.value = 1;
gain.connect(crusher);
crusher.connect(tempGain);
finalOutput = tempGain;
}
finalOutput.connect(musicGain);
// FX sends
if (deep) {
const delaySend = deep.delaySend / 100;
const reverbSend = deep.reverbSend / 100;
if (delaySend > 0 && leadFxDelay) {
const ds = ctx.createGain(); ds.gain.value = delaySend * 0.5;
finalOutput.connect(ds); ds.connect(leadFxDelay);
leadFxDelayWet.gain.value = Math.max(leadFxDelayWet.gain.value, 0.6);
}
if (reverbSend > 0 && leadFxReverb) {
const rs = ctx.createGain(); rs.gain.value = reverbSend * 0.4;
finalOutput.connect(rs); rs.connect(leadFxReverb);
leadFxReverbWet.gain.value = Math.max(leadFxReverbWet.gain.value, 0.5);
}
}
osc1.start(time);
osc2.start(time);
lfo.start(time);
osc1.stop(time + duration + 0.05);
osc2.stop(time + duration + 0.05);
lfo.stop(time + duration + 0.05);
}
// === GAME BOY WAVETABLE (XWAVE) ===
// 32-sample 4-bit wavetable like DMG wave channel
const XWAVE_SHAPES = {
sine: [8, 9, 11, 12, 13, 14, 15, 15, 15, 15, 14, 13, 12, 11, 9, 8, 7, 5, 4, 3, 2, 1, 0, 0, 0, 0, 1, 2, 3, 4, 5, 7],
saw: [0, 0, 1, 1, 2, 2, 3, 3, 4, 4, 5, 5, 6, 6, 7, 7, 8, 8, 9, 9, 10, 10, 11, 11, 12, 12, 13, 13, 14, 14, 15, 15],
square: [15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
tri: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 15, 14, 13, 12, 11, 10, 9, 8, 7, 6, 5, 4, 3, 2, 1, 0],
pulse25: [15, 15, 15, 15, 15, 15, 15, 15, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
organ: [8, 11, 14, 15, 14, 11, 8, 5, 2, 0, 2, 5, 8, 10, 12, 13, 12, 10, 8, 6, 4, 3, 4, 6, 8, 9, 10, 11, 10, 9, 8, 7],
bass: [15, 14, 12, 10, 8, 6, 4, 2, 1, 0, 0, 1, 2, 4, 6, 8, 10, 12, 14, 15, 15, 14, 12, 10, 8, 6, 4, 2, 1, 0, 0, 1],
noise: [8, 12, 3, 15, 1, 14, 6, 9, 2, 11, 5, 13, 0, 10, 7, 4, 15, 3, 12, 1, 14, 6, 9, 2, 11, 5, 13, 0, 10, 7, 4, 8]
};
let xwaveShape = 'organ'; // Default shape - nice Game Boy sound
function playXWaveLead(time, note = 60, vol = 0.12, overrides = {}) {
const ctx = initAudio();
// Get deep settings if applicable
const deep = getLeadSettingsForVoice('xwave');
const octShift = deep ? deep.octave * 12 : 0;
const driftC = deep && deep.drift ? (Math.random() - 0.5) * 2 * deep.drift : 0;
const detuneC = (deep ? deep.detune : 0) + driftC;
const volMult = deep ? deep.volume / 100 : 1;
const freq = 440 * Math.pow(2, (note + octShift - 69 + detuneC / 100) / 12);
// ADSR from deep settings
const atkMs = deep ? deep.attack : 10;
const decMs = overrides.decay ? (overrides.decay * 1000) : (deep ? deep.decay : 100);
const susLvl = deep ? deep.sustain / 100 : 0.7;
const relMs = deep ? deep.release : 200;
const duration = (atkMs + decMs + relMs) / 1000 + 0.1;
// Build PeriodicWave from 32-sample wavetable
const samples = XWAVE_SHAPES[xwaveShape] || XWAVE_SHAPES.organ;
const real = new Float32Array(17);
const imag = new Float32Array(17);
real[0] = 0;
imag[0] = 0;
// Convert 4-bit samples to harmonics via DFT approximation
for (let h = 1; h < 17; h++) {
let re = 0, im = 0;
for (let i = 0; i < 32; i++) {
const angle = -2 * Math.PI * h * i / 32;
const val = (samples[i] - 7.5) / 7.5; // Normalize to -1..1
re += val * Math.cos(angle);
im += val * Math.sin(angle);
}
real[h] = re / 32;
imag[h] = im / 32;
}
const useNorm = deep ? deep.normalize : true;
const wave = ctx.createPeriodicWave(real, imag, { disableNormalization: !useNorm });
const osc = ctx.createOscillator();
osc.setPeriodicWave(wave);
osc.frequency.setValueAtTime(freq, time);
// Vibrato from deep settings
const vibRate = deep ? deep.vibRate : 5;
const vibDepth = deep ? deep.vibDepth / 100 : 0.2;
const vibDelayMs = deep ? deep.vibDelay : 50;
const lfo = ctx.createOscillator();
const lfoGain = ctx.createGain();
lfo.frequency.setValueAtTime(vibRate + Math.random() * 1, time);
lfoGain.gain.setValueAtTime(0, time);
lfoGain.gain.setValueAtTime(freq * vibDepth * 0.04, time + vibDelayMs / 1000);
lfo.connect(lfoGain);
lfoGain.connect(osc.frequency);
// Filter from deep settings with occasional movement
const filter = ctx.createBiquadFilter();
filter.type = deep ? deep.filterType : 'lowpass';
const baseFilterCut = overrides.cutoff ? overrides.cutoff : (deep ? deep.filterCut : 2000 + freq * 2);
const baseQ = overrides.reso ? overrides.reso : (deep ? safariSafe('maxResonance', deep.filterRes) : 1.5);
// Dynamic filter: longer notes get sweeps, short notes random chance
const isLongNote = duration > 0.25;
const doFilterSweep = isLongNote ? Math.random() < 0.45 : Math.random() < 0.20;
if (doFilterSweep && !deep) {
// Filter sweep with resonance bump
const sweepQ = 4 + Math.random() * 6; // 4-10 resonance
const startCut = 800 + Math.random() * 600; // Start darker
const peakCut = Math.min(8000, freq * 6 + Math.random() * 2000);
filter.Q.setValueAtTime(safariSafe('maxResonance', sweepQ), time);
filter.frequency.setValueAtTime(startCut, time);
filter.frequency.exponentialRampToValueAtTime(peakCut, time + duration * 0.3);
filter.frequency.exponentialRampToValueAtTime(startCut * 1.5, time + duration * 0.9);
} else {
filter.frequency.setValueAtTime(baseFilterCut, time);
filter.Q.setValueAtTime(baseQ, time);
}
// ADSR envelope
const finalVol = vol * volMult;
const gain = ctx.createGain();
gain.gain.setValueAtTime(0.0001, time);
gain.gain.exponentialRampToValueAtTime(finalVol, time + atkMs / 1000);
gain.gain.exponentialRampToValueAtTime(Math.max(0.0001, finalVol * susLvl), time + (atkMs + decMs) / 1000);
gain.gain.setValueAtTime(Math.max(0.0001, finalVol * susLvl), time + (atkMs + decMs) / 1000 + 0.05);
gain.gain.exponentialRampToValueAtTime(0.0001, time + duration);
osc.connect(filter);
filter.connect(gain);
// Route through FX sends if enabled
const delaySend = deep ? deep.delaySend / 100 : 0;
const reverbSend = deep ? deep.reverbSend / 100 : 0;
const driveAmt = deep ? deep.drive / 100 : 0;
const chorusSend = deep ? deep.chorus / 100 : 0;
// Dry signal to main
gain.connect(musicGain);
// FX sends (if FX bus is initialized)
if (delaySend > 0 && leadFxDelay) {
const delaySendGain = ctx.createGain();
delaySendGain.gain.value = delaySend * 0.5;
gain.connect(delaySendGain);
delaySendGain.connect(leadFxDelay);
leadFxDelayWet.gain.value = Math.max(leadFxDelayWet.gain.value, 0.6);
}
if (reverbSend > 0 && leadFxReverb) {
const reverbSendGain = ctx.createGain();
reverbSendGain.gain.value = reverbSend * 0.4;
gain.connect(reverbSendGain);
reverbSendGain.connect(leadFxReverb);
leadFxReverbWet.gain.value = Math.max(leadFxReverbWet.gain.value, 0.5);
}
if (driveAmt > 0 && leadFxDrive) {
leadFxDrive.curve = makeLeadDriveCurve(driveAmt);
const driveSendGain = ctx.createGain();
driveSendGain.gain.value = driveAmt * 0.3;
gain.connect(driveSendGain);
driveSendGain.connect(leadFxDrive);
}
if (chorusSend > 0 && leadFxChorus) {
const chorusSendGain = ctx.createGain();
chorusSendGain.gain.value = chorusSend * 0.4;
gain.connect(chorusSendGain);
chorusSendGain.connect(leadFxChorus);
leadFxChorusWet.gain.value = Math.max(leadFxChorusWet.gain.value, 0.5);
}
osc.start(time);
lfo.start(time);
osc.stop(time + duration + 0.02);
lfo.stop(time + duration + 0.02);
}
// === XWAVE EDITOR FUNCTIONS ===
let xwaveCustom = null; // Custom drawn wavetable (null = use preset)
let xwaveDrawing = false;
let xwaveTheme = 0; // 0=DMG, 1=C64, 2=Amber
let xwaveSelectedPreset = null; // Track selected preset including 'random'
const XWAVE_THEMES = [
{ name: 'DMG', title: '🎮 GAME BOY WAVETABLE', bg: '#0F380F', grid: '#306230', wave: '#9BBC0F', point: '#9BBC0F', border: '#306230', btn: '#9BBC0F' },
{ name: 'C64', title: '💾 COMMODORE 64 SID', bg: '#40318D', grid: '#6C5EB5', wave: '#70A4B2', point: '#70A4B2', border: '#6C5EB5', btn: '#70A4B2' },
{ name: 'CRT', title: '📺 AMBER CRT TERMINAL', bg: '#1A0F00', grid: '#3D2200', wave: '#FFB000', point: '#FFCC00', border: '#5A3300', btn: '#FFB000' }
];
// Lead voice parameter state
let leadParams = {
sid: { pw: 50, detune: 8, filter: 3000, gain: 100 },
chip: { pw: 35, arp: 50, vib: 30, gain: 100 },
fm: { bend: 12, mod: 8, vib: 40, gain: 100 },
pad: { atk: 100, rel: 800, flt: 1200, gain: 100 },
brass: { atk: 20, bright: 10, dec: 180, gain: 100 },
poly: { dist: 60, detune: 15, q: 8, gain: 100 },
mars: { wobble: 35, speed: 15, formant: 1200, click: 50, gain: 100 }
};
function updateLeadVoicePanel() {
// Check both old and new dropdowns (prefer new panel dropdown)
const newSelect = document.getElementById('voicePanelMode');
const oldSelect = document.getElementById('seqMotifVoice');
const voice = newSelect ? newSelect.value : (oldSelect ? oldSelect.value : '');
// Sync the two dropdowns
if (newSelect && oldSelect && newSelect.value !== oldSelect.value) {
newSelect.value = oldSelect.value;
}
// Hide all config panels
document.querySelectorAll('.lead-config').forEach(el => el.style.display = 'none');
// Show the appropriate one
const voiceMap = {
'xwave': 'xwave',
'xwaveX': 'xwave', // Hybrid uses xwave panel
'xwave3d': 'xwave', // Hybrid uses xwave panel
'sidLead': 'sidLead',
'chipLead': 'chipLead',
'fmWhammy': 'fmWhammy',
'fmPad': 'fmPad',
'fmBrass': 'fmBrass',
'xpoly': 'xpoly',
'marsLead': 'marsLead',
'': 'random'
};
const configId = voiceMap[voice] || 'random';
const configEl = document.getElementById('leadConfig-' + configId);
if (configEl) configEl.style.display = 'block';
// Update panel border color based on voice
const panel = document.getElementById('leadVoicePanel');
const colors = {
'xwave': '#9BBC0F',
'sidLead': '#70A4B2',
'chipLead': '#0FF',
'fmWhammy': '#F0F',
'fmPad': '#88F',
'fmBrass': '#FA0',
'xpoly': '#A0F',
'marsLead': '#FF4',
'xwaveX': '#CF0', // Yellow-green (xwave + partner)
'xwave3d': '#8F8', // Pastel green (3D stereo)
'': '#FA0'
};
if (panel) panel.style.borderColor = colors[voice] || '#FA0';
// Initialize xwave canvas if needed
if (voice === 'xwave') {
renderXWaveCanvas();
initXWaveDrawing();
updateXWavePresetHighlight();
updateXWaveThemeHighlight();
}
// Sync lead gain slider with current value
const leadGainSlider = document.getElementById('leadMotifGain');
const leadGainVal = document.getElementById('leadMotifGainVal');
if (leadGainSlider && typeof leadVoiceSettings !== 'undefined') {
leadGainSlider.value = leadVoiceSettings.volume || 100;
if (leadGainVal) leadGainVal.textContent = (leadVoiceSettings.volume || 100) + '%';
}
// Sync lead universal controls
syncLeadUniversalControls();
}
// Backwards compat
function updateXWavePanelVisibility() {
updateLeadVoicePanel();
}
// New consolidated panel functions
function switchVoicePanel(voiceType) {
const modeSelector = document.getElementById('voicePanelMode');
if (!modeSelector) return;
// Update panel border color and DEEP button color based on voice type
const panel = document.getElementById('leadVoicePanel');
const deepBtn = document.getElementById('voiceDeepBtn');
const colors = {
'kick': '#F44',
'snare': '#FA4',
'hihat': '#FF4',
'bass': '#4F4',
'pad': '#4FF',
'lead': '#F0F'
};
const color = colors[voiceType] || '#FA0';
if (panel) panel.style.borderColor = color;
if (deepBtn) {
deepBtn.style.borderColor = color;
deepBtn.style.color = color;
}
// Populate mode selector based on voice type
modeSelector.innerHTML = '';
if (voiceType === 'lead') {
// Lead synth engines
const leadOptions = [
{ value: '', label: '(Random)' },
{ value: 'xwave', label: 'GB Wave' },
{ value: 'sidLead', label: 'SID Lead' },
{ value: 'chipLead', label: 'Chip Lead' },
{ value: 'marsLead', label: 'Mars Lead' },
{ value: 'xpoly', label: 'Poly' },
{ value: 'fmWhammy', label: 'FM Wham' },
{ value: 'fmPad', label: 'FM Pad' },
{ value: 'fmBrass', label: 'FM Brass' }
];
leadOptions.forEach(opt => {
const option = document.createElement('option');
option.value = opt.value;
option.textContent = opt.label;
modeSelector.appendChild(option);
});
modeSelector.style.display = 'block';
// Show appropriate lead config
updateLeadVoicePanel();
} else if (voiceType === 'bass') {
// Bass motif engines
const bassOptions = [
{ value: '', label: '(Random)' },
{ value: 'classic', label: 'Classic (SID)' },
{ value: 'tuss', label: 'Tuss (WET)' },
{ value: '808buzz', label: '808 Buzz' }
];
bassOptions.forEach(opt => {
const option = document.createElement('option');
option.value = opt.value;
option.textContent = opt.label;
modeSelector.appendChild(option);
});
// Get current bass motif for first bass voice (bass0)
const currentBassMotif = seqBassMotif['bass0'] || seqBassStyle || '';
modeSelector.value = currentBassMotif;
modeSelector.style.display = 'block';
// Hide all lead-specific configs
document.querySelectorAll('.lead-config').forEach(el => el.style.display = 'none');
} else if (voiceType === 'hihat') {
// Hat synthesis engines
const hatOptions = [
{ value: '', label: '(Random)' },
{ value: '808', label: '808 (Classic)' },
{ value: '606', label: '606 (Tight)' },
{ value: 'noise', label: 'Noise (Analog)' },
{ value: 'chip', label: 'Chip (LFSR)' },
{ value: 'gb', label: 'Game Boy' },
{ value: 'psg', label: 'PSG (Sega)' }
];
hatOptions.forEach(opt => {
const option = document.createElement('option');
option.value = opt.value;
option.textContent = opt.label;
modeSelector.appendChild(option);
});
// Get current hat motif for first hat voice (hat0)
const currentHatMotif = seqHatMotif['hat0'] || '';
modeSelector.value = currentHatMotif;
modeSelector.style.display = 'block';
// Hide all lead-specific configs
document.querySelectorAll('.lead-config').forEach(el => el.style.display = 'none');
} else {
// Kick, snare, pad - currently single engines, but show selector for future expansion
const defaultOptions = [
{ value: 'default', label: 'Default' }
];
defaultOptions.forEach(opt => {
const option = document.createElement('option');
option.value = opt.value;
option.textContent = opt.label;
modeSelector.appendChild(option);
});
modeSelector.value = 'default';
modeSelector.style.display = 'block';
// Hide all lead-specific configs
document.querySelectorAll('.lead-config').forEach(el => el.style.display = 'none');
}
// Show/hide lead-only gain slider
const leadGainRow = document.getElementById('leadGainRow');
if (leadGainRow) {
leadGainRow.style.display = (voiceType === 'lead') ? 'flex' : 'none';
}
// Show/hide lead universal controls (ADSR, Filter, etc)
const leadUniversal = document.getElementById('leadUniversalControls');
if (leadUniversal) {
leadUniversal.style.display = (voiceType === 'lead') ? 'block' : 'none';
if (voiceType === 'lead') {
syncLeadUniversalControls();
}
}
// Show/hide voice-specific quick controls
document.querySelectorAll('.voice-quick-controls').forEach(el => el.style.display = 'none');
const quickPanel = document.getElementById('voiceQuick-' + voiceType);
if (quickPanel) {
quickPanel.style.display = 'block';
// Sync slider values with current deep settings
syncVoiceQuickControls(voiceType);
}
logEvolution(`MOTIF → ${voiceType.toUpperCase()}`);
}
function setLeadMotifGain(value) {
const gainVal = parseInt(value) || 100;
// Update display
const gainValEl = document.getElementById('leadMotifGainVal');
if (gainValEl) gainValEl.textContent = gainVal + '%';
// Update leadVoiceSettings
if (typeof leadVoiceSettings !== 'undefined') {
leadVoiceSettings.volume = gainVal;
// Sync with DEEP settings slider
const lvVolSlider = document.getElementById('lvVolume');
const lvVolVal = document.getElementById('lvVolVal');
if (lvVolSlider) lvVolSlider.value = gainVal;
if (lvVolVal) lvVolVal.textContent = gainVal + '%';
}
logEvolution(`LEAD GAIN → ${gainVal}%`);
}
// Lead universal param handler (ADSR, Filter, Detune)
function setLeadUniversalParam(param, value) {
const val = parseInt(value) || 0;
if (typeof leadVoiceSettings === 'undefined') return;
if (param === 'attack') {
leadVoiceSettings.attack = val;
document.getElementById('leadQuickAttackVal').textContent = val + 'ms';
// Sync with deep modal
const deepEl = document.getElementById('lvAttack');
const deepVal = document.getElementById('lvAtkVal');
if (deepEl) deepEl.value = val;
if (deepVal) deepVal.textContent = val + 'ms';
} else if (param === 'release') {
leadVoiceSettings.release = val;
document.getElementById('leadQuickReleaseVal').textContent = val + 'ms';
const deepEl = document.getElementById('lvRelease');
const deepVal = document.getElementById('lvRelVal');
if (deepEl) deepEl.value = val;
if (deepVal) deepVal.textContent = val + 'ms';
} else if (param === 'cutoff') {
leadVoiceSettings.filterCut = val;
const disp = val >= 1000 ? (val / 1000).toFixed(1) + 'k' : val + 'Hz';
document.getElementById('leadQuickCutoffVal').textContent = disp;
const deepEl = document.getElementById('lvFilterCut');
const deepVal = document.getElementById('lvCutVal');
if (deepEl) deepEl.value = val;
if (deepVal) deepVal.textContent = disp;
} else if (param === 'reso') {
leadVoiceSettings.filterRes = val;
document.getElementById('leadQuickResoVal').textContent = val;
const deepEl = document.getElementById('lvFilterRes');
const deepVal = document.getElementById('lvResVal');
if (deepEl) deepEl.value = val;
if (deepVal) deepVal.textContent = val;
} else if (param === 'detune') {
leadVoiceSettings.detune = val;
document.getElementById('leadQuickDetuneVal').textContent = val + 'c';
const deepEl = document.getElementById('lvDetune');
const deepVal = document.getElementById('lvDetuneVal');
if (deepEl) deepEl.value = val;
if (deepVal) deepVal.textContent = val + 'c';
}
logEvolution(`LEAD ${param.toUpperCase()}${value}`);
}
// Sync lead universal controls with current deep settings
function syncLeadUniversalControls() {
if (typeof leadVoiceSettings === 'undefined') return;
const s = leadVoiceSettings;
// Attack
const atkEl = document.getElementById('leadQuickAttack');
const atkVal = document.getElementById('leadQuickAttackVal');
if (atkEl) atkEl.value = s.attack || 10;
if (atkVal) atkVal.textContent = (s.attack || 10) + 'ms';
// Release
const relEl = document.getElementById('leadQuickRelease');
const relVal = document.getElementById('leadQuickReleaseVal');
if (relEl) relEl.value = s.release || 250;
if (relVal) relVal.textContent = (s.release || 250) + 'ms';
// Cutoff
const cutEl = document.getElementById('leadQuickCutoff');
const cutVal = document.getElementById('leadQuickCutoffVal');
if (cutEl) cutEl.value = s.filterCut || 3000;
if (cutVal) {
const f = s.filterCut || 3000;
cutVal.textContent = f >= 1000 ? (f / 1000).toFixed(1) + 'k' : f + 'Hz';
}
// Resonance
const resEl = document.getElementById('leadQuickReso');
const resVal = document.getElementById('leadQuickResoVal');
if (resEl) resEl.value = s.filterRes || 5;
if (resVal) resVal.textContent = (s.filterRes || 5);
// Detune
const detEl = document.getElementById('leadQuickDetune');
const detVal = document.getElementById('leadQuickDetuneVal');
if (detEl) detEl.value = s.detune || 0;
if (detVal) detVal.textContent = (s.detune || 0) + 'c';
}
// Sync quick controls with current deep settings when switching panels
function syncVoiceQuickControls(voiceType) {
if (voiceType === 'kick') {
const pitchEl = document.getElementById('kickQuickPitch');
const pitchVal = document.getElementById('kickQuickPitchVal');
const decayEl = document.getElementById('kickQuickDecay');
const decayVal = document.getElementById('kickQuickDecayVal');
if (pitchEl) pitchEl.value = kickDeepSettings.pitch || 55;
if (pitchVal) pitchVal.textContent = (kickDeepSettings.pitch || 55) + 'Hz';
if (decayEl) decayEl.value = kickDeepSettings.decay || 150;
if (decayVal) decayVal.textContent = (kickDeepSettings.decay || 150) + 'ms';
} else if (voiceType === 'snare') {
const snappyEl = document.getElementById('snareQuickSnappy');
const snappyVal = document.getElementById('snareQuickSnappyVal');
const decayEl = document.getElementById('snareQuickDecay');
const decayVal = document.getElementById('snareQuickDecayVal');
if (snappyEl) snappyEl.value = snareDeepSettings.snappy || 60;
if (snappyVal) snappyVal.textContent = (snareDeepSettings.snappy || 60) + '%';
if (decayEl) decayEl.value = snareDeepSettings.decay || 150;
if (decayVal) decayVal.textContent = (snareDeepSettings.decay || 150) + 'ms';
} else if (voiceType === 'hihat') {
const toneEl = document.getElementById('hihatQuickTone');
const toneVal = document.getElementById('hihatQuickToneVal');
const mixEl = document.getElementById('hihatQuickMix');
const mixVal = document.getElementById('hihatQuickMixVal');
const ringEl = document.getElementById('hihatQuickRing');
const ringVal = document.getElementById('hihatQuickRingVal');
if (toneEl) toneEl.value = hihatDeepSettings.tone || 8000;
if (toneVal) toneVal.textContent = Math.round((hihatDeepSettings.tone || 8000) / 1000) + 'kHz';
if (mixEl) mixEl.value = hihatDeepSettings.mix || 5;
if (mixVal) mixVal.textContent = (hihatDeepSettings.mix || 5) + '%';
if (ringEl) ringEl.value = hihatDeepSettings.ring || 30;
if (ringVal) ringVal.textContent = (hihatDeepSettings.ring || 30) + '%';
} else if (voiceType === 'bass') {
const filterEl = document.getElementById('bassQuickFilter');
const filterVal = document.getElementById('bassQuickFilterVal');
if (filterEl) filterEl.value = bassDeepSettings.filter || 1200;
if (filterVal) {
const f = bassDeepSettings.filter || 1200;
filterVal.textContent = f >= 1000 ? (f / 1000).toFixed(1) + 'kHz' : f + 'Hz';
}
} else if (voiceType === 'pad') {
const attackEl = document.getElementById('padQuickAttack');
const attackVal = document.getElementById('padQuickAttackVal');
const filterEl = document.getElementById('padQuickFilter');
const filterVal = document.getElementById('padQuickFilterVal');
const resoEl = document.getElementById('padQuickReso');
const resoVal = document.getElementById('padQuickResoVal');
if (attackEl) attackEl.value = padDeepSettings.attack || 70;
if (attackVal) attackVal.textContent = (padDeepSettings.attack || 70) + 'ms';
if (filterEl) filterEl.value = padDeepSettings.filter || 2000;
if (filterVal) {
const f = padDeepSettings.filter || 2000;
filterVal.textContent = f >= 1000 ? Math.round(f / 1000) + 'kHz' : f + 'Hz';
}
if (resoEl) resoEl.value = padDeepSettings.reso || 5;
if (resoVal) resoVal.textContent = (padDeepSettings.reso || 5);
}
}
// Handle quick param changes and sync to deep settings
function setVoiceQuickParam(voice, param, value) {
const val = parseInt(value) || 0;
if (voice === 'kick') {
kickDeepSettings[param] = val;
if (param === 'pitch') {
document.getElementById('kickQuickPitchVal').textContent = val + 'Hz';
// Sync with deep modal
const deepEl = document.getElementById('kickDeepPitch');
const deepVal = document.getElementById('kickDeepPitchVal');
if (deepEl) deepEl.value = val;
if (deepVal) deepVal.textContent = val + 'Hz';
} else if (param === 'decay') {
document.getElementById('kickQuickDecayVal').textContent = val + 'ms';
const deepEl = document.getElementById('kickDeepDecay');
const deepVal = document.getElementById('kickDeepDecayVal');
if (deepEl) deepEl.value = val;
if (deepVal) deepVal.textContent = val + 'ms';
}
} else if (voice === 'snare') {
snareDeepSettings[param] = val;
if (param === 'snappy') {
document.getElementById('snareQuickSnappyVal').textContent = val + '%';
const deepEl = document.getElementById('snareDeepSnappy');
const deepVal = document.getElementById('snareDeepSnappyVal');
if (deepEl) deepEl.value = val;
if (deepVal) deepVal.textContent = val + '%';
} else if (param === 'decay') {
document.getElementById('snareQuickDecayVal').textContent = val + 'ms';
const deepEl = document.getElementById('snareDeepDecay');
const deepVal = document.getElementById('snareDeepDecayVal');
if (deepEl) deepEl.value = val;
if (deepVal) deepVal.textContent = val + 'ms';
}
} else if (voice === 'hihat') {
hihatDeepSettings[param] = val;
if (param === 'tone') {
const kHz = Math.round(val / 1000);
document.getElementById('hihatQuickToneVal').textContent = kHz + 'kHz';
const deepEl = document.getElementById('hihatDeepTone');
const deepVal = document.getElementById('hihatDeepToneVal');
if (deepEl) deepEl.value = val;
if (deepVal) deepVal.textContent = kHz + 'kHz';
} else if (param === 'mix') {
document.getElementById('hihatQuickMixVal').textContent = val + '%';
} else if (param === 'ring') {
document.getElementById('hihatQuickRingVal').textContent = val + '%';
const deepEl = document.getElementById('hihatDeepRing');
const deepVal = document.getElementById('hihatDeepRingVal');
if (deepEl) deepEl.value = val;
if (deepVal) deepVal.textContent = val + '%';
}
} else if (voice === 'bass') {
bassDeepSettings[param] = val;
if (param === 'filter') {
const disp = val >= 1000 ? (val / 1000).toFixed(1) + 'kHz' : val + 'Hz';
document.getElementById('bassQuickFilterVal').textContent = disp;
const deepEl = document.getElementById('bassDeepFilter');
const deepVal = document.getElementById('bassDeepFilterVal');
if (deepEl) deepEl.value = val;
if (deepVal) deepVal.textContent = disp;
}
} else if (voice === 'pad') {
padDeepSettings[param] = val;
if (param === 'attack') {
document.getElementById('padQuickAttackVal').textContent = val + 'ms';
const deepEl = document.getElementById('padDeepAttack');
const deepVal = document.getElementById('padDeepAttackVal');
if (deepEl) deepEl.value = val;
if (deepVal) deepVal.textContent = val + 'ms';
} else if (param === 'filter') {
const disp = val >= 1000 ? Math.round(val / 1000) + 'kHz' : val + 'Hz';
document.getElementById('padQuickFilterVal').textContent = disp;
const deepEl = document.getElementById('padDeepFilter');
const deepVal = document.getElementById('padDeepFilterVal');
if (deepEl) deepEl.value = val;
if (deepVal) deepVal.textContent = disp;
} else if (param === 'reso') {
document.getElementById('padQuickResoVal').textContent = val;
const deepEl = document.getElementById('padDeepReso');
const deepVal = document.getElementById('padDeepResoVal');
if (deepEl) deepEl.value = val;
if (deepVal) deepVal.textContent = val;
}
}
logEvolution(`${voice.toUpperCase()} ${param.toUpperCase()}${value}`);
}
// Reset voice quick controls to defaults
function resetVoiceQuick(voice) {
if (voice === 'kick') {
Object.assign(kickDeepSettings, kickDeepDefaults);
syncVoiceQuickControls('kick');
} else if (voice === 'snare') {
Object.assign(snareDeepSettings, snareDeepDefaults);
syncVoiceQuickControls('snare');
} else if (voice === 'hihat') {
Object.assign(hihatDeepSettings, hihatDeepDefaults);
syncVoiceQuickControls('hihat');
} else if (voice === 'bass') {
Object.assign(bassDeepSettings, bassDeepDefaults);
seqBassStyle = bassDeepDefaults.style || 'classic';
syncVoiceQuickControls('bass');
} else if (voice === 'pad') {
Object.assign(padDeepSettings, padDeepDefaults);
syncVoiceQuickControls('pad');
}
logEvolution(`${voice.toUpperCase()} → RESET`);
}
function switchVoiceMode(mode) {
const voiceSelector = document.getElementById('voicePanelSelector');
const voiceType = voiceSelector ? voiceSelector.value : 'lead';
if (voiceType === 'lead') {
// Sync with the existing motif voice system
const oldSelect = document.getElementById('seqMotifVoice');
if (oldSelect) oldSelect.value = mode;
// Apply the setting
applySeqSetting('motif', mode);
// Update the panel display
updateLeadVoicePanel();
logEvolution(`LEAD ENGINE → ${mode || 'Random'}`);
} else if (voiceType === 'bass') {
// Set bass motif for all bass voices (or per-voice in future)
// For now, apply to bass0 (first bass voice)
// If mode is empty, delete the key so it falls back to default
if (mode && mode !== '') {
seqBassMotif['bass0'] = mode;
} else {
delete seqBassMotif['bass0'];
}
// Also update legacy seqBassStyle for compatibility
if (mode === 'tuss' || mode === 'classic') {
seqBassStyle = mode;
} else if (!mode || mode === '') {
// Reset to default if Random
seqBassStyle = 'classic';
}
if (musicSettings) {
// Update music settings if available
const musicBassSelect = document.getElementById('musicBassStyle');
if (musicBassSelect && (mode === 'tuss' || mode === 'classic')) {
musicBassSelect.value = mode;
}
}
const modeLabel = mode === 'tuss' ? 'Tuss (WET)' : (mode === '808buzz' ? '808 Buzz' : (mode === 'classic' ? 'Classic (SID)' : 'Random'));
logEvolution(`BASS MOTIF → ${modeLabel}`);
} else if (voiceType === 'hihat') {
// Set hat motif for all hat voices (or per-voice in future)
// For now, apply to hat0 (first hat voice)
seqHatMotif['hat0'] = mode || '';
// Could also apply to all hat voices: voiceList.filter(v => v.type === 'hihat').forEach(v => { seqHatMotif[v.id] = mode || ''; });
logEvolution(`HAT MOTIF → ${mode || 'Random'}`);
} else {
// Kick, snare, pad - future sound engine selection
logEvolution(`${voiceType.toUpperCase()} ENGINE → ${mode}`);
}
}
// Sync new panel dropdown with old dropdown
function syncVoicePanelDropdowns() {
const oldSelect = document.getElementById('seqMotifVoice');
const newSelect = document.getElementById('voicePanelMode');
if (oldSelect && newSelect) {
newSelect.value = oldSelect.value;
}
}
// Parameter update functions
function updateSidParam(param, value) {
leadParams.sid[param] = parseInt(value);
const valEl = document.getElementById('sid' + param.charAt(0).toUpperCase() + param.slice(1) + 'Val');
if (valEl) valEl.textContent = param === 'gain' ? value + '%' : (param === 'filter' ? (value / 1000).toFixed(1) + 'k' : value);
}
function updateChipParam(param, value) {
leadParams.chip[param] = parseInt(value);
const valEl = document.getElementById('chip' + param.charAt(0).toUpperCase() + param.slice(1) + 'Val');
if (valEl) valEl.textContent = param === 'gain' ? value + '%' : value;
}
function updateFmParam(param, value) {
leadParams.fm[param] = parseInt(value);
const valEl = document.getElementById('fm' + param.charAt(0).toUpperCase() + param.slice(1) + 'Val');
if (valEl) valEl.textContent = param === 'gain' ? value + '%' : value;
}
function updatePadParam(param, value) {
leadParams.pad[param] = parseInt(value);
const valEl = document.getElementById('pad' + param.charAt(0).toUpperCase() + param.slice(1) + 'Val');
if (valEl) valEl.textContent = param === 'gain' ? value + '%' : (param === 'flt' ? (value / 1000).toFixed(1) + 'k' : value);
}
function updateBrassParam(param, value) {
leadParams.brass[param] = parseInt(value);
const valEl = document.getElementById('brass' + param.charAt(0).toUpperCase() + param.slice(1) + 'Val');
if (valEl) valEl.textContent = param === 'gain' ? value + '%' : value;
}
function updatePolyParam(param, value) {
leadParams.poly[param] = parseInt(value);
const valEl = document.getElementById('poly' + param.charAt(0).toUpperCase() + param.slice(1) + 'Val');
if (valEl) valEl.textContent = param === 'gain' ? value + '%' : value;
}
function updateMarsParam(param, value) {
leadParams.mars[param] = parseInt(value);
const valEl = document.getElementById('mars' + param.charAt(0).toUpperCase() + param.slice(1) + 'Val');
if (valEl) {
if (param === 'gain') valEl.textContent = value + '%';
else if (param === 'speed') valEl.textContent = (value / 10).toFixed(1);
else if (param === 'formant') valEl.textContent = (value / 1000).toFixed(1) + 'k';
else valEl.textContent = value;
}
}
// Mars mutation algorithms
function mutateMarsParams() {
// Different mutation modes for variety
const mode = Math.floor(Math.random() * 5);
let newParams;
switch (mode) {
case 0: // Pure random
newParams = {
wobble: Math.floor(Math.random() * 100),
speed: Math.floor(5 + Math.random() * 35),
formant: Math.floor(600 + Math.random() * 1900),
click: Math.floor(Math.random() * 100),
gain: Math.floor(70 + Math.random() * 60) // Keep gain reasonable
};
break;
case 1: // Extreme wobble (dying battery)
newParams = {
wobble: Math.floor(60 + Math.random() * 40),
speed: Math.floor(20 + Math.random() * 20),
formant: Math.floor(800 + Math.random() * 800),
click: Math.floor(20 + Math.random() * 40),
gain: 100
};
break;
case 2: // Clean toy organ
newParams = {
wobble: Math.floor(Math.random() * 20),
speed: Math.floor(5 + Math.random() * 10),
formant: Math.floor(1000 + Math.random() * 600),
click: Math.floor(60 + Math.random() * 40),
gain: 100
};
break;
case 3: // Dark & warbly
newParams = {
wobble: Math.floor(40 + Math.random() * 40),
speed: Math.floor(5 + Math.random() * 15),
formant: Math.floor(600 + Math.random() * 400),
click: Math.floor(Math.random() * 30),
gain: Math.floor(80 + Math.random() * 40)
};
break;
case 4: // Bright & twitchy
newParams = {
wobble: Math.floor(30 + Math.random() * 50),
speed: Math.floor(25 + Math.random() * 15),
formant: Math.floor(1600 + Math.random() * 900),
click: Math.floor(50 + Math.random() * 50),
gain: Math.floor(90 + Math.random() * 30)
};
break;
}
// Apply and sync UI
Object.assign(leadParams.mars, newParams);
syncMarsUI();
const modeNames = ['RANDOM', 'DYING BATTERY', 'CLEAN TOY', 'DARK WARBLY', 'BRIGHT TWITCHY'];
logEvolution(`MARS MUTATE → ${modeNames[mode]}`);
}
function resetMarsParams() {
leadParams.mars = { wobble: 35, speed: 15, formant: 1200, click: 50, gain: 100 };
syncMarsUI();
logEvolution('MARS → RESET');
}
function syncMarsUI() {
const p = leadParams.mars;
document.getElementById('marsWobble').value = p.wobble;
document.getElementById('marsWobbleVal').textContent = p.wobble;
document.getElementById('marsSpeed').value = p.speed;
document.getElementById('marsSpeedVal').textContent = (p.speed / 10).toFixed(1);
document.getElementById('marsFormant').value = p.formant;
document.getElementById('marsFormantVal').textContent = (p.formant / 1000).toFixed(1) + 'k';
document.getElementById('marsClick').value = p.click;
document.getElementById('marsClickVal').textContent = p.click;
document.getElementById('marsGain').value = p.gain;
document.getElementById('marsGainVal').textContent = p.gain + '%';
}
// Random voice mode controls
let randomVoiceHold = false;
let randomVoiceOctave = 1;
let currentRandomVoice = null;
let recentRandomVoices = []; // Track last N voices to avoid repetition
const RANDOM_VOICE_POOL = ['sidLead', 'chipLead', 'marsLead', 'xpoly', 'xwave', 'xwaveX', 'xwave3d', 'fmWhammy', 'fmPad', 'fmBrass', 'acid'];
const RANDOM_VOICE_HISTORY_SIZE = 3; // Avoid repeating last 3 voices
function shuffleRandomVoice() {
// Filter out recently used voices
const available = RANDOM_VOICE_POOL.filter(v => !recentRandomVoices.includes(v));
// Pick from available, or full pool if all recently used
const pool = available.length > 0 ? available : RANDOM_VOICE_POOL;
currentRandomVoice = pool[Math.floor(Math.random() * pool.length)];
// Track history
recentRandomVoices.push(currentRandomVoice);
if (recentRandomVoices.length > RANDOM_VOICE_HISTORY_SIZE) {
recentRandomVoices.shift();
}
const display = document.getElementById('randomCurrentVoice');
if (display) display.textContent = currentRandomVoice.replace(/([A-Z])/g, ' $1').trim().toUpperCase();
// Update the lead voice dropdown to match
const leadSelect = document.getElementById('seqMotifVoice');
if (leadSelect) {
leadSelect.value = currentRandomVoice;
updateLeadVoicePanel();
}
logEvolution(`SHUFFLE → ${currentRandomVoice}`);
}
function toggleRandomHold() {
randomVoiceHold = !randomVoiceHold;
const btn = document.getElementById('randomHoldBtn');
if (btn) {
btn.style.borderColor = randomVoiceHold ? '#F0F' : '#666';
btn.style.color = randomVoiceHold ? '#F0F' : '#666';
btn.style.background = randomVoiceHold ? '#301030' : '#222';
}
}
function updateRandomOctave(val) {
randomVoiceOctave = parseInt(val);
const display = document.getElementById('randomOctVal');
if (display) display.textContent = val > 0 ? '+' + val : val;
}
function getRandomLeadVoice() {
if (randomVoiceHold && currentRandomVoice) return currentRandomVoice;
// Smart selection: avoid recently used voices
const available = RANDOM_VOICE_POOL.filter(v => !recentRandomVoices.includes(v));
const pool = available.length > 0 ? available : RANDOM_VOICE_POOL;
currentRandomVoice = pool[Math.floor(Math.random() * pool.length)];
// Track history
recentRandomVoices.push(currentRandomVoice);
if (recentRandomVoices.length > RANDOM_VOICE_HISTORY_SIZE) {
recentRandomVoices.shift();
}
const display = document.getElementById('randomCurrentVoice');
if (display) display.textContent = currentRandomVoice.replace(/([A-Z])/g, ' $1').trim().toUpperCase();
return currentRandomVoice;
}
function renderXWaveCanvas() {
const canvas = document.getElementById('xwaveCanvas');
if (!canvas) return;
const ctx = canvas.getContext('2d');
const w = canvas.width, h = canvas.height;
const theme = XWAVE_THEMES[xwaveTheme];
ctx.fillStyle = theme.bg;
ctx.fillRect(0, 0, w, h);
// Get current samples
const samples = xwaveCustom || XWAVE_SHAPES[xwaveShape] || XWAVE_SHAPES.organ;
// Draw grid
ctx.strokeStyle = theme.grid;
ctx.lineWidth = 1;
for (let i = 0; i < 32; i++) {
const x = (i / 32) * w;
ctx.beginPath();
ctx.moveTo(x, 0);
ctx.lineTo(x, h);
ctx.stroke();
}
ctx.beginPath();
ctx.moveTo(0, h / 2);
ctx.lineTo(w, h / 2);
ctx.stroke();
// Draw waveform with glow effect
ctx.shadowColor = theme.wave;
ctx.shadowBlur = 8;
ctx.strokeStyle = theme.wave;
ctx.lineWidth = 2;
ctx.beginPath();
for (let i = 0; i < 32; i++) {
const x = (i / 32) * w + (w / 64);
const y = h - ((samples[i] / 15) * h);
if (i === 0) ctx.moveTo(x, y);
else ctx.lineTo(x, y);
}
ctx.stroke();
ctx.shadowBlur = 0;
// Draw sample points
ctx.fillStyle = theme.point;
for (let i = 0; i < 32; i++) {
const x = (i / 32) * w + (w / 64);
const y = h - ((samples[i] / 15) * h);
ctx.beginPath();
ctx.arc(x, y, 3, 0, Math.PI * 2);
ctx.fill();
}
}
function setXWaveTheme(idx) {
xwaveTheme = idx;
applyXWaveTheme();
renderXWaveCanvas();
updateXWaveThemeHighlight();
}
function cycleXWaveTheme() {
xwaveTheme = (xwaveTheme + 1) % XWAVE_THEMES.length;
applyXWaveTheme();
renderXWaveCanvas();
updateXWaveThemeHighlight();
}
function updateXWaveThemeHighlight() {
const themeIds = ['xwaveThemeDMG', 'xwaveThemeC64', 'xwaveThemeCRT'];
themeIds.forEach((id, idx) => {
const btn = document.getElementById(id);
if (btn) {
btn.style.boxShadow = idx === xwaveTheme ? '0 0 8px #FFF, 0 0 12px #FFF' : 'none';
}
});
}
function applyXWaveTheme() {
const theme = XWAVE_THEMES[xwaveTheme];
const canvas = document.getElementById('xwaveCanvas');
const buttons = document.getElementById('xwaveButtons');
if (canvas) {
canvas.style.background = theme.bg;
canvas.style.borderColor = theme.border;
}
if (buttons) {
buttons.querySelectorAll('.xwave-btn').forEach(btn => {
if (!btn.textContent.includes('RND')) {
btn.style.borderColor = theme.btn;
btn.style.color = theme.btn;
}
});
}
}
function initXWaveDrawing() {
const canvas = document.getElementById('xwaveCanvas');
if (!canvas || canvas._xwaveInit) return;
canvas._xwaveInit = true;
const draw = (e) => {
if (!xwaveDrawing) return;
const rect = canvas.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
const sampleIdx = Math.floor((x / rect.width) * 32);
const sampleVal = Math.round((1 - (y / rect.height)) * 15);
if (sampleIdx >= 0 && sampleIdx < 32) {
if (!xwaveCustom) {
xwaveCustom = [...(XWAVE_SHAPES[xwaveShape] || XWAVE_SHAPES.organ)];
}
xwaveCustom[sampleIdx] = Math.max(0, Math.min(15, sampleVal));
renderXWaveCanvas();
}
};
canvas.addEventListener('mousedown', (e) => {
xwaveDrawing = true;
// Clear preset selection when user starts drawing
if (xwaveSelectedPreset !== null) {
xwaveSelectedPreset = null;
updateXWavePresetHighlight();
}
draw(e);
});
canvas.addEventListener('mousemove', draw);
canvas.addEventListener('mouseup', () => { xwaveDrawing = false; });
canvas.addEventListener('mouseleave', () => { xwaveDrawing = false; });
// Touch support
canvas.addEventListener('touchstart', (e) => {
e.preventDefault();
xwaveDrawing = true;
// Clear preset selection when user starts drawing
if (xwaveSelectedPreset !== null) {
xwaveSelectedPreset = null;
updateXWavePresetHighlight();
}
const touch = e.touches[0];
draw({ clientX: touch.clientX, clientY: touch.clientY });
});
canvas.addEventListener('touchmove', (e) => {
e.preventDefault();
const touch = e.touches[0];
draw({ clientX: touch.clientX, clientY: touch.clientY });
});
canvas.addEventListener('touchend', () => { xwaveDrawing = false; });
}
function setXWavePreset(preset) {
xwaveShape = preset;
xwaveCustom = null; // Clear custom, use preset
xwaveSelectedPreset = preset; // Track selection
renderXWaveCanvas();
updateXWavePresetHighlight();
logEvolution(`WAVE → ${preset.toUpperCase()}`);
}
function updateXWavePresetHighlight() {
const buttons = document.getElementById('xwaveButtons');
if (!buttons) return;
const theme = XWAVE_THEMES[xwaveTheme];
buttons.querySelectorAll('.xwave-btn').forEach(btn => {
const isRnd = btn.textContent.includes('RND');
const presetName = btn.onclick ? btn.onclick.toString().match(/'(\w+)'/) : null;
const btnPreset = presetName ? presetName[1] : null;
if (isRnd) {
// RND button - highlight if selected
const isActive = xwaveSelectedPreset === 'random';
btn.style.boxShadow = isActive ? '0 0 8px #FF0, 0 0 12px #FF0' : 'none';
btn.style.borderColor = isActive ? '#FFF' : '#FF0';
} else {
// Preset buttons - highlight if selected
const isActive = btnPreset === xwaveSelectedPreset;
btn.style.boxShadow = isActive ? '0 0 8px #FFF, 0 0 12px #FFF' : 'none';
btn.style.borderColor = isActive ? '#FFF' : theme.btn;
}
});
}
function randomizeXWave() {
xwaveCustom = Array.from({ length: 32 }, () => Math.floor(Math.random() * 16));
xwaveSelectedPreset = 'random'; // Track RND selection
renderXWaveCanvas();
updateXWavePresetHighlight();
logEvolution('WAVE → RANDOM');
}
// Override playXWaveLead to use custom wavetable if drawn
const _origPlayXWaveLead = playXWaveLead;
playXWaveLead = function (time, note = 60, vol = 0.12) {
// If no custom wavetable, use original function (which has deep settings)
if (!xwaveCustom) {
return _origPlayXWaveLead(time, note, vol);
}
const ctx = initAudio();
// Get deep settings if applicable
const deep = getLeadSettingsForVoice('xwave');
const octShift = deep ? deep.octave * 12 : 0;
const driftC = deep && deep.drift ? (Math.random() - 0.5) * 2 * deep.drift : 0;
const detuneC = (deep ? deep.detune : 0) + driftC;
const volMult = deep ? deep.volume / 100 : 1;
const atkMs = deep ? deep.attack : 8;
const decMs = deep ? deep.decay : 50;
const susLvl = deep ? deep.sustain / 100 : 0.8;
const relMs = deep ? deep.release : 120;
const freq = 440 * Math.pow(2, (note + octShift - 69 + detuneC / 100) / 12);
const duration = deep ? (atkMs + decMs + relMs) / 1000 + 0.05 : 0.22 + Math.random() * 0.12;
// Use custom samples
const samples = xwaveCustom;
const real = new Float32Array(17);
const imag = new Float32Array(17);
real[0] = 0;
imag[0] = 0;
for (let h = 1; h < 17; h++) {
let re = 0, im = 0;
for (let i = 0; i < 32; i++) {
const angle = -2 * Math.PI * h * i / 32;
const val = (samples[i] - 7.5) / 7.5;
re += val * Math.cos(angle);
im += val * Math.sin(angle);
}
real[h] = re / 32;
imag[h] = im / 32;
}
const useNorm = deep ? deep.normalize : true;
const wave = ctx.createPeriodicWave(real, imag, { disableNormalization: !useNorm });
const osc = ctx.createOscillator();
osc.setPeriodicWave(wave);
osc.frequency.setValueAtTime(freq, time);
// Vibrato - use deep settings
const vibRate = deep ? deep.vibRate : 5 + Math.random() * 2;
const vibDepth = deep ? deep.vibDepth / 100 : 0.008;
const vibDelayMs = deep ? deep.vibDelay : 50;
const lfo = ctx.createOscillator();
const lfoGain = ctx.createGain();
lfo.frequency.setValueAtTime(vibRate, time);
lfoGain.gain.setValueAtTime(0, time);
lfoGain.gain.setValueAtTime(freq * vibDepth * 0.04, time + vibDelayMs / 1000);
lfo.connect(lfoGain);
lfoGain.connect(osc.frequency);
// Filter - use deep settings
const filter = ctx.createBiquadFilter();
filter.type = deep ? deep.filterType : 'lowpass';
filter.frequency.setValueAtTime(deep ? deep.filterCut : 2000 + freq * 2, time);
filter.Q.setValueAtTime(deep ? safariSafe('maxResonance', deep.filterRes) : 1.5, time);
// ADSR envelope
const finalVol = vol * volMult;
const gain = ctx.createGain();
gain.gain.setValueAtTime(0.0001, time);
gain.gain.exponentialRampToValueAtTime(finalVol, time + atkMs / 1000);
gain.gain.exponentialRampToValueAtTime(Math.max(0.0001, finalVol * susLvl), time + (atkMs + decMs) / 1000);
gain.gain.exponentialRampToValueAtTime(0.0001, time + duration);
osc.connect(filter);
filter.connect(gain);
gain.connect(musicGain);
// FX sends
if (deep) {
const delaySend = deep.delaySend / 100;
const reverbSend = deep.reverbSend / 100;
if (delaySend > 0 && leadFxDelay) {
const ds = ctx.createGain(); ds.gain.value = delaySend * 0.5;
gain.connect(ds); ds.connect(leadFxDelay);
leadFxDelayWet.gain.value = Math.max(leadFxDelayWet.gain.value, 0.6);
}
if (reverbSend > 0 && leadFxReverb) {
const rs = ctx.createGain(); rs.gain.value = reverbSend * 0.4;
gain.connect(rs); rs.connect(leadFxReverb);
leadFxReverbWet.gain.value = Math.max(leadFxReverbWet.gain.value, 0.5);
}
}
osc.start(time);
lfo.start(time);
osc.stop(time + duration + 0.02);
lfo.stop(time + duration + 0.02);
};
// === XWAVE HYBRID VOICES ===
// xwaveX: xwave layered with a random second motif (rotates each call)
const XWAVE_X_POOL = ['chipLead', 'sidLead', 'marsLead', 'fmPad', 'fmBrass'];
let xwaveXPartner = null;
let xwaveXCounter = 0;
function playXWaveX(time, note = 60, vol = 0.14, overrides = {}) {
// Layer 1: Always xwave (primary)
playXWaveLead(time, note, vol * 0.6, overrides);
// Layer 2: Rotate partner voice every 4 notes
if (xwaveXCounter % 4 === 0 || !xwaveXPartner) {
xwaveXPartner = XWAVE_X_POOL[Math.floor(Math.random() * XWAVE_X_POOL.length)];
}
xwaveXCounter++;
// Play partner at lower volume, slightly detuned
const partnerVol = vol * 0.35;
const partnerNote = note + (Math.random() < 0.3 ? 12 : 0); // 30% chance octave up
if (xwaveXPartner === 'chipLead') playChipLead(time, partnerNote, partnerVol, true);
else if (xwaveXPartner === 'sidLead') playXSIDLeadAccent(time, partnerNote, partnerVol);
else if (xwaveXPartner === 'marsLead') playMarsLead(time, partnerNote, partnerVol);
else if (xwaveXPartner === 'fmPad') playFMPad(time, partnerNote, partnerVol * 0.8, true);
else if (xwaveXPartner === 'fmBrass') playFMBrassStab(time, partnerNote, partnerVol * 0.7);
}
// xwave3d: xwave with stereo width and depth via detuned layers
function playXWave3D(time, note = 60, vol = 0.14, overrides = {}) {
// Center layer
playXWaveLead(time, note, vol * 0.5, overrides);
// Slightly detuned for stereo width (simulated via timing offset)
playXWaveLead(time + 0.008, note, vol * 0.25, { ...overrides, detune: 7 });
playXWaveLead(time + 0.004, note, vol * 0.25, { ...overrides, detune: -7 });
// Sub octave for depth (30% of the time)
if (Math.random() < 0.3) {
playXWaveLead(time, note - 12, vol * 0.2, overrides);
}
}
function playXPolyLead(time, note = 60, vol = 0.03, overrides = {}) {
const ctx = initAudio();
// Get deep settings if applicable
const deep = getLeadSettingsForVoice('xpoly');
const octShift = deep ? deep.octave * 12 : 0;
const driftC = deep && deep.drift ? (Math.random() - 0.5) * 2 * deep.drift : 0;
const detuneC = (deep ? deep.detune : 0) + driftC;
const volMult = deep ? deep.volume / 100 : 1;
const freq = 440 * Math.pow(2, (note + octShift - 69 + detuneC / 100) / 12);
const baseDuration = 0.18;
const dDecay = overrides.decay ? overrides.decay : (deep ? (deep.attack + deep.decay + deep.release) / 1000 : baseDuration);
const duration = dDecay + 0.02;
const o1 = ctx.createOscillator();
const o2 = ctx.createOscillator();
const filter = ctx.createBiquadFilter();
const shaper = ctx.createWaveShaper();
const gain = ctx.createGain();
// Softer triangle for o1, square for texture
o1.type = 'triangle';
o2.type = 'square';
// === PITCH EXPRESSION ===
// Occasional pitch bends for that edge between game-classic and soulful
const isLongNote = duration > 0.2;
const doPitchBend = !deep && (isLongNote ? Math.random() < 0.35 : Math.random() < 0.12);
const doVibrato = !deep && duration > 0.15 && Math.random() < 0.30;
if (doPitchBend) {
// Bend types: scoop up, fall down, or wobble
const bendType = Math.random();
if (bendType < 0.4) {
// Scoop up from below (bluesy)
const startFreq = freq * (0.92 + Math.random() * 0.05);
o1.frequency.setValueAtTime(startFreq, time);
o2.frequency.setValueAtTime(startFreq * 1.005, time);
o1.frequency.exponentialRampToValueAtTime(freq, time + duration * 0.25);
o2.frequency.exponentialRampToValueAtTime(freq * 1.005, time + duration * 0.25);
} else if (bendType < 0.7) {
// Fall down at end (expressive release)
o1.frequency.setValueAtTime(freq, time);
o2.frequency.setValueAtTime(freq * 1.005, time);
const endFreq = freq * (0.85 + Math.random() * 0.1);
o1.frequency.setValueAtTime(freq, time + duration * 0.6);
o1.frequency.exponentialRampToValueAtTime(endFreq, time + duration * 0.95);
o2.frequency.setValueAtTime(freq * 1.005, time + duration * 0.6);
o2.frequency.exponentialRampToValueAtTime(endFreq * 1.005, time + duration * 0.95);
} else {
// Quick wobble/trill (game-ish but alive)
o1.frequency.setValueAtTime(freq, time);
o2.frequency.setValueAtTime(freq * 1.005, time);
const wobbleFreq = freq * (1.02 + Math.random() * 0.03);
o1.frequency.setValueAtTime(wobbleFreq, time + duration * 0.3);
o1.frequency.setValueAtTime(freq, time + duration * 0.5);
o2.frequency.setValueAtTime(wobbleFreq * 1.005, time + duration * 0.3);
o2.frequency.setValueAtTime(freq * 1.005, time + duration * 0.5);
}
} else {
o1.frequency.setValueAtTime(freq, time);
o2.frequency.setValueAtTime(freq * 1.005, time);
}
// Vibrato LFO (delayed onset for natural feel)
if (doVibrato) {
const vibLfo = ctx.createOscillator();
const vibGain = ctx.createGain();
vibLfo.type = 'sine';
vibLfo.frequency.value = 5 + Math.random() * 2; // 5-7 Hz
vibGain.gain.setValueAtTime(0, time);
vibGain.gain.linearRampToValueAtTime(0, time + duration * 0.4); // Delay onset
vibGain.gain.linearRampToValueAtTime(freq * 0.012, time + duration * 0.7); // Gentle depth
vibLfo.connect(vibGain);
vibGain.connect(o1.frequency);
vibGain.connect(o2.frequency);
vibLfo.start(time);
vibLfo.stop(time + duration + 0.02);
}
// Filter with occasional sweep - use deep settings, softer defaults
filter.type = deep ? deep.filterType : 'lowpass';
const baseFilterCut = overrides.cutoff ? overrides.cutoff : (deep ? deep.filterCut : Math.min(4000, Math.max(600, freq * 3)));
const baseQ = overrides.reso ? overrides.reso : (deep ? safariSafe('maxResonance', deep.filterRes) : 2 + Math.random() * 2);
// Dynamic filter: longer notes get sweeps, short notes random chance
const doFilterSweep = isLongNote ? Math.random() < 0.40 : Math.random() < 0.15;
if (doFilterSweep && !deep) {
const sweepQ = 4 + Math.random() * 5;
const startCut = 700 + Math.random() * 500;
const peakCut = Math.min(5000, freq * 5 + Math.random() * 1500);
filter.Q.setValueAtTime(safariSafe('maxResonance', sweepQ), time);
filter.frequency.setValueAtTime(startCut, time);
filter.frequency.exponentialRampToValueAtTime(peakCut, time + duration * 0.35);
filter.frequency.exponentialRampToValueAtTime(startCut * 1.3, time + duration * 0.9);
} else {
filter.frequency.setValueAtTime(baseFilterCut, time);
filter.Q.setValueAtTime(baseQ, time);
}
// Gentler saturation curve
const k = 30;
const curve = new Float32Array(2048);
for (let i = 0; i < curve.length; i++) {
const x = (i * 2) / (curve.length - 1) - 1;
curve[i] = ((1 + k) * x) / (1 + k * Math.abs(x));
}
shaper.curve = curve;
shaper.oversample = '4x';
// Envelope - use deep ADSR
const finalVol = vol * volMult;
const atkMs = deep ? deep.attack : 10;
gain.gain.setValueAtTime(0.0001, time);
gain.gain.exponentialRampToValueAtTime(finalVol, time + atkMs / 1000);
gain.gain.exponentialRampToValueAtTime(0.0001, time + duration);
o1.connect(filter);
o2.connect(filter);
filter.connect(shaper);
shaper.connect(gain);
gain.connect(musicGain);
// FX sends
if (deep) {
const delaySend = deep.delaySend / 100;
const reverbSend = deep.reverbSend / 100;
if (delaySend > 0 && leadFxDelay) {
const ds = ctx.createGain(); ds.gain.value = delaySend * 0.5;
gain.connect(ds); ds.connect(leadFxDelay);
leadFxDelayWet.gain.value = Math.max(leadFxDelayWet.gain.value, 0.6);
}
if (reverbSend > 0 && leadFxReverb) {
const rs = ctx.createGain(); rs.gain.value = reverbSend * 0.4;
gain.connect(rs); rs.connect(leadFxReverb);
leadFxReverbWet.gain.value = Math.max(leadFxReverbWet.gain.value, 0.5);
}
}
o1.start(time);
o2.start(time);
o1.stop(time + duration + 0.02);
o2.stop(time + duration + 0.02);
}
const XSID_LEAD_PRESETS = [
// Koshiro-ish: nasal BP saw+pulse-ish, slight detune - SCREAMING reso
{ o1: 'sawtooth', o2: 'square', o2Ratio: 1.01, fType: 'bandpass', q: [6.0, 12.0], cutMulA: [3.0, 8.0], cutMulB: [0.8, 2.0] },
// Hollow tri+square: LP with bite
{ o1: 'triangle', o2: 'square', o2Ratio: 1.006, fType: 'lowpass', q: [5.0, 10.0], cutMulA: [4.0, 10.0], cutMulB: [1.0, 2.5] },
// Buzzy saw+saw: brighter, aggressive sweep
{ o1: 'sawtooth', o2: 'sawtooth', o2Ratio: 1.012, fType: 'lowpass', q: [8.0, 14.0], cutMulA: [5.0, 12.0], cutMulB: [0.8, 1.8] },
// Chirpy HP: thin but resonant
{ o1: 'square', o2: 'triangle', o2Ratio: 0.996, fType: 'highpass', q: [4.0, 8.0], cutMulA: [1.5, 4.0], cutMulB: [1.0, 2.0] },
// Short BP stab: classic SID scream
{ o1: 'square', o2: 'square', o2Ratio: 1.018, fType: 'bandpass', q: [8.0, 15.0], cutMulA: [2.5, 6.0], cutMulB: [0.6, 1.4] }
];
const XSID_BASS_PRESETS = [
// Classic rubbery SID bass - juicy resonance, wide sweep
{ o: 'square', sub: 'sine', q: [6.0, 8.0], a: [140, 280], peak: [1400, 2800], tail: [100, 180] },
// Saw-ish bass: brighter, squelchy
{ o: 'sawtooth', sub: 'sine', q: [5.5, 8.0], a: [160, 320], peak: [1800, 3500], tail: [120, 220] },
// Thuddy but bouncy - darker but still juicy
{ o: 'square', sub: 'triangle', q: [6.5, 8.0], a: [120, 240], peak: [1200, 2400], tail: [90, 160] }
];
function playXSIDLeadAccent(time, note = 60, vol = 0.07, overrides = {}) {
const ctx = initAudio();
// Get deep settings if applicable
const deep = getLeadSettingsForVoice('sidLead');
const octShift = deep ? deep.octave * 12 : 0;
const driftC = deep && deep.drift ? (Math.random() - 0.5) * 2 * deep.drift : 0;
const detuneC = (deep ? deep.detune : 0) + driftC;
const volMult = deep ? deep.volume / 100 : 1;
const freq = 440 * Math.pow(2, (note + octShift - 69 + detuneC / 100) / 12);
const baseDuration = 0.18 + Math.random() * 0.12;
const dDecay = overrides.decay ? overrides.decay : (deep ? (deep.attack + deep.decay + deep.release) / 1000 : baseDuration);
const dAttack = overrides.attack !== undefined ? overrides.attack : 0.01;
const duration = dAttack + dDecay + 0.02;
const pIdx = (breakbeat && breakbeat.xsid && Number.isFinite(breakbeat.xsid.leadPresetIndex))
? breakbeat.xsid.leadPresetIndex
: Math.floor(Math.random() * XSID_LEAD_PRESETS.length);
const preset = XSID_LEAD_PRESETS[Math.max(0, Math.min(XSID_LEAD_PRESETS.length - 1, pIdx))];
const osc1 = ctx.createOscillator();
const osc2 = ctx.createOscillator();
const filter = ctx.createBiquadFilter();
const gain = ctx.createGain();
osc1.type = preset.o1;
osc2.type = preset.o2;
osc1.frequency.setValueAtTime(freq, time);
osc2.frequency.setValueAtTime(freq * preset.o2Ratio, time);
// Filter - use deep settings if available
filter.type = deep ? deep.filterType : preset.fType;
const presetQ = preset.q[0] + Math.random() * (preset.q[1] - preset.q[0]);
const qVal = overrides.reso ? overrides.reso : (deep ? deep.filterRes : presetQ);
filter.Q.setValueAtTime(safariSafe('maxResonance', qVal), time);
const filterStart = overrides.cutoff ? overrides.cutoff : (deep ? deep.filterCut : Math.min(9000, Math.max(180, freq * (preset.cutMulA[0] + Math.random() * (preset.cutMulA[1] - preset.cutMulA[0])))));
const filterEnd = deep ? deep.filterCut * 0.3 : Math.min(9000, Math.max(180, freq * (preset.cutMulB[0] + Math.random() * (preset.cutMulB[1] - preset.cutMulB[0]))));
filter.frequency.setValueAtTime(filterStart, time);
filter.frequency.exponentialRampToValueAtTime(Math.max(20, filterEnd), time + duration);
// Envelope - use deep ADSR or override
const finalVol = vol * volMult;
gain.gain.setValueAtTime(0.0001, time);
gain.gain.linearRampToValueAtTime(finalVol, time + dAttack);
// If "reverse" (long attack), we hold until end, then cut
if (dAttack > 0.1) {
gain.gain.setValueAtTime(finalVol, time + duration - 0.02);
gain.gain.exponentialRampToValueAtTime(0.0001, time + duration);
} else {
gain.gain.exponentialRampToValueAtTime(0.0001, time + duration + 0.06);
}
// Soft limiter to tame peaks
const limiter = ctx.createWaveShaper();
const limiterCurve = new Float32Array(256);
for (let i = 0; i < 256; i++) {
const x = (i / 128) - 1;
// Soft clip at ~0.7
limiterCurve[i] = Math.tanh(x * 1.4) * 0.7;
}
limiter.curve = limiterCurve;
limiter.oversample = '2x';
osc1.connect(filter);
osc2.connect(filter);
filter.connect(gain);
gain.connect(limiter);
limiter.connect(musicGain);
// FX sends
if (deep) {
const delaySend = deep.delaySend / 100;
const reverbSend = deep.reverbSend / 100;
if (delaySend > 0 && leadFxDelay) {
const ds = ctx.createGain(); ds.gain.value = delaySend * 0.5;
limiter.connect(ds); ds.connect(leadFxDelay);
leadFxDelayWet.gain.value = Math.max(leadFxDelayWet.gain.value, 0.6);
}
if (reverbSend > 0 && leadFxReverb) {
const rs = ctx.createGain(); rs.gain.value = reverbSend * 0.4;
limiter.connect(rs); rs.connect(leadFxReverb);
leadFxReverbWet.gain.value = Math.max(leadFxReverbWet.gain.value, 0.5);
}
}
osc1.start(time);
osc2.start(time);
osc1.stop(time + duration + 0.07);
osc2.stop(time + duration + 0.07);
}
function playXSIDBassAccent(time, note = 36, vol = 0.18) {
const ctx = initAudio();
const freq = 440 * Math.pow(2, (note - 69) / 12);
const duration = 0.18 + Math.random() * 0.12; // Longer for rubbery sustain
const pIdx = (breakbeat && breakbeat.xsid && Number.isFinite(breakbeat.xsid.bassPresetIndex))
? breakbeat.xsid.bassPresetIndex
: Math.floor(Math.random() * XSID_BASS_PRESETS.length);
const preset = XSID_BASS_PRESETS[Math.max(0, Math.min(XSID_BASS_PRESETS.length - 1, pIdx))];
const osc = ctx.createOscillator();
const sub = ctx.createOscillator();
const filter = ctx.createBiquadFilter();
const gain = ctx.createGain();
osc.type = preset.o;
sub.type = preset.sub;
// Occasional pitch slide (30% chance) - Drexciya style
const doSlide = Math.random() < 0.30;
const slideFrom = doSlide ? freq * (Math.random() < 0.5 ? 0.85 : 1.15) : freq;
osc.frequency.setValueAtTime(slideFrom, time);
sub.frequency.setValueAtTime(slideFrom * 0.5, time);
if (doSlide) {
osc.frequency.exponentialRampToValueAtTime(freq, time + 0.04);
sub.frequency.exponentialRampToValueAtTime(freq * 0.5, time + 0.04);
}
// Subtle pitch vibrato (40% chance) - Aphex wobble
const doVibrato = Math.random() < 0.40;
if (doVibrato) {
const vibLfo = ctx.createOscillator();
const vibGain = ctx.createGain();
vibLfo.type = 'sine';
vibLfo.frequency.setValueAtTime(4 + Math.random() * 4, time); // 4-8 Hz wobble
vibGain.gain.setValueAtTime(0, time);
vibGain.gain.linearRampToValueAtTime(freq * 0.012, time + 0.05); // Fade in vibrato
vibGain.gain.linearRampToValueAtTime(freq * 0.018, time + duration * 0.7);
vibLfo.connect(vibGain);
vibGain.connect(osc.frequency);
vibGain.connect(sub.frequency);
vibLfo.start(time);
vibLfo.stop(time + duration + 0.1);
}
// Occasional filter wobble (25% chance) - extra motion
const doFilterWobble = Math.random() < 0.25;
filter.type = 'lowpass';
const bassQ = preset.q[0] + Math.random() * (preset.q[1] - preset.q[0]);
filter.Q.setValueAtTime(safariSafe('maxResonance', bassQ), time);
filter.frequency.setValueAtTime(preset.a[0] + Math.random() * (preset.a[1] - preset.a[0]), time);
filter.frequency.exponentialRampToValueAtTime(preset.peak[0] + Math.random() * (preset.peak[1] - preset.peak[0]), time + 0.03);
filter.frequency.exponentialRampToValueAtTime(preset.tail[0] + Math.random() * (preset.tail[1] - preset.tail[0]), time + duration);
if (doFilterWobble) {
const filterLfo = ctx.createOscillator();
const filterLfoGain = ctx.createGain();
filterLfo.type = 'sine';
filterLfo.frequency.setValueAtTime(3 + Math.random() * 5, time); // 3-8 Hz
filterLfoGain.gain.setValueAtTime(200 + Math.random() * 400, time); // Subtle wobble depth
filterLfo.connect(filterLfoGain);
filterLfoGain.connect(filter.frequency);
filterLfo.start(time);
filterLfo.stop(time + duration + 0.1);
}
gain.gain.setValueAtTime(0.0001, time);
gain.gain.exponentialRampToValueAtTime(vol, time + 0.008);
gain.gain.exponentialRampToValueAtTime(0.0001, time + duration + 0.06);
osc.connect(filter);
sub.connect(filter);
filter.connect(gain);
gain.connect(musicGain);
osc.start(time);
sub.start(time);
osc.stop(time + duration + 0.07);
sub.stop(time + duration + 0.07);
}
function parsePackPattern(str) {
const result = [];
let i = 0;
while (i < str.length && result.length < 16) {
const char = str[i];
if (char === 'x' || char === 'X') {
result.push(char === 'X' ? 127 : 1);
i++;
} else if (char === '.') {
result.push(0);
i++;
} else if (/[0-9A-Fa-f]/.test(char) && i + 1 < str.length) {
const hex = str.substr(i, 2);
result.push(parseInt(hex, 16));
i += 2;
} else {
i++;
}
}
while (result.length < 16) result.push(0);
return result;
}
// === SPACE INVADERS MUSIC (Name Entry / Leaderboard) ===
// Classic descending bass line from 1010seq
const spaceMusic = {
bpm: 100,
step: 0,
intervalId: null,
patterns: {
// Descending chromatic bass - the iconic Space Invaders march
bass: [40, 0, 38, 0, 36, 0, 34, 0, 33, 0, 31, 0, 29, 0, 28, 0],
// Four-on-floor kick
kick: [1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0],
// Noise hits on offbeats
noise: [0, 0, 1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0, 1, 0]
}
};
function playSpaceBass(time, note, vol = 0.35) {
const ctx = initAudio();
const freq = 440 * Math.pow(2, (note - 69) / 12);
// Square wave for that retro arcade sound
const osc = ctx.createOscillator();
osc.type = 'square';
osc.frequency.value = freq;
// Lowpass for warmth
const filter = ctx.createBiquadFilter();
filter.type = 'lowpass';
filter.frequency.value = 600;
const gain = ctx.createGain();
gain.gain.setValueAtTime(vol, time);
gain.gain.exponentialRampToValueAtTime(0.001, time + 0.15);
osc.connect(filter);
filter.connect(gain);
gain.connect(musicGain);
osc.start(time);
osc.stop(time + 0.2);
}
function playSpaceKick(time, vol = 0.3) {
const ctx = initAudio();
const osc = ctx.createOscillator();
osc.type = 'sine';
osc.frequency.setValueAtTime(150, time);
osc.frequency.exponentialRampToValueAtTime(40, time + 0.1);
const gain = ctx.createGain();
gain.gain.setValueAtTime(vol, time);
gain.gain.exponentialRampToValueAtTime(0.001, time + 0.15);
osc.connect(gain);
gain.connect(musicGain);
osc.start(time);
osc.stop(time + 0.2);
}
function playSpaceNoise(time, vol = 0.1) {
const ctx = initAudio();
const noise = ctx.createBufferSource();
noise.buffer = noiseBuffers.short;
const filter = ctx.createBiquadFilter();
filter.type = 'bandpass';
filter.frequency.value = 4000;
filter.Q.value = 2;
const gain = ctx.createGain();
gain.gain.setValueAtTime(vol, time);
gain.gain.exponentialRampToValueAtTime(0.001, time + 0.05);
noise.connect(filter);
filter.connect(gain);
gain.connect(musicGain);
noise.start(time);
}
function startDubRiddim() {
if (spaceMusic.intervalId) return;
const ctx = initAudio();
spaceMusic.step = 0;
const stepTime = 60.0 / spaceMusic.bpm / 4; // 16th notes
spaceMusic.intervalId = setInterval(() => {
if (!musicEnabled) return;
const time = ctx.currentTime + 0.05;
const step = spaceMusic.step % 16;
if (spaceMusic.patterns.bass[step]) {
playSpaceBass(time, spaceMusic.patterns.bass[step]);
}
if (spaceMusic.patterns.kick[step]) {
playSpaceKick(time);
}
if (spaceMusic.patterns.noise[step]) {
playSpaceNoise(time);
}
spaceMusic.step++;
}, stepTime * 1000);
}
function stopDubRiddim() {
if (spaceMusic.intervalId) {
clearInterval(spaceMusic.intervalId);
spaceMusic.intervalId = null;
}
}
// Ghost note voices - accents
function playRimshot(time, vol = 0.3) {
const ctx = initAudio();
const noise = ctx.createBufferSource();
noise.buffer = noiseBuffers.short;
const filter = ctx.createBiquadFilter();
filter.type = 'bandpass';
filter.frequency.value = 3500;
filter.Q.value = 3;
const gain = ctx.createGain();
gain.gain.setValueAtTime(vol, time);
gain.gain.exponentialRampToValueAtTime(0.001, time + 0.08);
noise.connect(filter);
filter.connect(gain);
gain.connect(musicGain);
noise.start(time);
}
function playShaker(time, vol = 0.1) {
const ctx = initAudio();
const noise = ctx.createBufferSource();
noise.buffer = noiseBuffers.short;
const filter = ctx.createBiquadFilter();
filter.type = 'bandpass';
filter.frequency.value = 6000;
filter.Q.value = 1.5;
const gain = ctx.createGain();
gain.gain.setValueAtTime(vol, time);
gain.gain.exponentialRampToValueAtTime(0.001, time + 0.1);
noise.connect(filter);
filter.connect(gain);
gain.connect(musicGain);
noise.start(time);
}
function playCowbell(time, vol = 0.12) {
const ctx = initAudio();
const osc1 = ctx.createOscillator();
const osc2 = ctx.createOscillator();
const gain = ctx.createGain();
osc1.type = 'square';
osc1.frequency.value = 800;
osc2.type = 'square';
osc2.frequency.value = 540;
gain.gain.setValueAtTime(vol, time);
gain.gain.exponentialRampToValueAtTime(0.001, time + 0.1);
osc1.connect(gain);
osc2.connect(gain);
gain.connect(musicGain);
osc1.start(time);
osc2.start(time);
osc1.stop(time + 0.1);
osc2.stop(time + 0.1);
}
// === DRUM POP (Cross Stick / Rimshot Style) ===
// Like in sequenzersystem's Cross Stick preset
// Bright, short, percussive pop for accents
// Occasionally triggers random ratchet bursts
// === TRANSFORMER SCRATCH (A-Trak style) ===
// Simulates "ra-ta" chirp pattern with white noise + pitch sweep
function playTransformerScratch(time, vol = 0.4) {
const ctx = initAudio();
if (!noiseBuffers || !noiseBuffers.short) return;
// White noise burst
const noise = ctx.createBufferSource();
noise.buffer = noiseBuffers.short;
// Pitch sweep: 200Hz → 2000Hz (forward scratch emulation)
const filter = ctx.createBiquadFilter();
filter.type = 'bandpass';
filter.frequency.setValueAtTime(200, time);
filter.frequency.exponentialRampToValueAtTime(2000, time + 0.05);
filter.Q.value = 10; // Sharp resonance
const gain = ctx.createGain();
gain.gain.setValueAtTime(0, time);
gain.gain.linearRampToValueAtTime(vol, time + 0.001); // Sharp attack
gain.gain.exponentialRampToValueAtTime(0.001, time + 0.05); // 50ms burst
noise.connect(filter);
filter.connect(gain);
gain.connect(musicGain);
noise.start(time);
noise.stop(time + 0.05);
}
function playPop(time, vol = 0.2, options = {}) {
const ctx = initAudio();
// Random ratchet burst (10% chance by default)
const ratchetChance = options.ratchetChance || 0.10;
const isRatchet = Math.random() < ratchetChance;
const ratchetCount = isRatchet ? Math.floor(Math.random() * 3) + 2 : 1; // 2-4 hits
for (let r = 0; r < ratchetCount; r++) {
const ratchetTime = time + (r * 0.04); // 40ms between ratchet hits
const ratchetVol = vol * (1 - (r * 0.2)); // Decay velocity for ratchets
// Noise burst (high, bright)
const noise = ctx.createBufferSource();
noise.buffer = noiseBuffers.short;
// High bandpass for that bright "pop" character
const filter = ctx.createBiquadFilter();
filter.type = 'bandpass';
filter.frequency.value = 6000 + Math.random() * 2000; // 6-8kHz
filter.Q.value = 4;
// Tonal component (high pitched ping)
const osc = ctx.createOscillator();
osc.type = 'sine';
const pitch = 1200 + Math.random() * 400; // High pitched 1200-1600Hz
osc.frequency.setValueAtTime(pitch, ratchetTime);
osc.frequency.exponentialRampToValueAtTime(pitch * 0.8, ratchetTime + 0.05);
// Mix
const noiseGain = ctx.createGain();
noiseGain.gain.setValueAtTime(ratchetVol * 0.6, ratchetTime);
noiseGain.gain.exponentialRampToValueAtTime(0.001, ratchetTime + 0.06);
const oscGain = ctx.createGain();
oscGain.gain.setValueAtTime(ratchetVol * 0.4, ratchetTime);
oscGain.gain.exponentialRampToValueAtTime(0.001, ratchetTime + 0.08);
// Connect
noise.connect(filter);
filter.connect(noiseGain);
noiseGain.connect(musicGain);
osc.connect(oscGain);
oscGain.connect(musicGain);
// Start
noise.start(ratchetTime);
osc.start(ratchetTime);
osc.stop(ratchetTime + 0.1);
}
}
function playTom(time, vol = 0.2, pitchMult = 1.0) {
const ctx = initAudio();
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.type = 'sine';
const basePitch = (100 + Math.random() * 80) * pitchMult; // Pitch controlled by Y position
osc.frequency.setValueAtTime(basePitch * 1.5, time);
osc.frequency.exponentialRampToValueAtTime(basePitch, time + 0.08);
gain.gain.setValueAtTime(vol, time);
gain.gain.exponentialRampToValueAtTime(0.001, time + 0.2);
osc.connect(gain);
gain.connect(musicGain);
osc.start(time);
osc.stop(time + 0.2);
}
function playConga(time, vol = 0.15, pitchMult = 1.0) {
const ctx = initAudio();
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.type = 'sine';
osc.frequency.setValueAtTime(200 * pitchMult, time);
osc.frequency.exponentialRampToValueAtTime(100 * pitchMult, time + 0.07);
gain.gain.setValueAtTime(vol, time);
gain.gain.exponentialRampToValueAtTime(0.001, time + 0.12);
osc.connect(gain);
gain.connect(musicGain);
osc.start(time);
osc.stop(time + 0.12);
}
function playRide(time, vol = 0.12, pitchMult = 1.0) {
const ctx = initAudio();
// Metallic cluster (909-style)
const fund = 300 * pitchMult;
const ratios = [1, 1.45, 1.9, 2.5];
const gain = ctx.createGain();
gain.gain.setValueAtTime(vol, time);
gain.gain.exponentialRampToValueAtTime(0.001, time + 0.6); // Long decay
// Highpass for sheen
const hp = ctx.createBiquadFilter();
hp.type = 'highpass';
hp.frequency.value = 4000;
hp.connect(gain);
gain.connect(musicGain);
ratios.forEach(r => {
const osc = ctx.createOscillator();
osc.type = 'square';
osc.frequency.value = fund * r;
osc.connect(hp);
osc.start(time);
osc.stop(time + 0.65);
});
}
// === VERSATILE SUB BASS (14th voice) ===
// Clean powerful sine sub - simple and LOUD
function playSub(time, note = 36, vol = 0.8, opts = {}) {
const ctx = initAudio();
const freq = 440 * Math.pow(2, (note - 69) / 12);
const decay = opts.decay || 0.4;
// Main sine oscillator
const osc = ctx.createOscillator();
osc.type = 'sine';
osc.frequency.setValueAtTime(freq * 1.5, time); // Slight pitch drop
osc.frequency.exponentialRampToValueAtTime(freq, time + 0.05);
// Sub layer - octave below for weight
const subOsc = ctx.createOscillator();
subOsc.type = 'sine';
subOsc.frequency.value = freq * 0.5;
// Gain nodes
const mainGain = ctx.createGain();
const subGainNode = ctx.createGain();
const masterGain = ctx.createGain();
// Connect oscillators
osc.connect(mainGain);
subOsc.connect(subGainNode);
mainGain.connect(masterGain);
subGainNode.connect(masterGain);
// Set gains - main at full, sub at 0.5
mainGain.gain.value = 1.0;
subGainNode.gain.value = 0.5;
// Envelope - punchy attack, smooth decay
masterGain.gain.setValueAtTime(0, time);
masterGain.gain.linearRampToValueAtTime(vol, time + 0.008);
masterGain.gain.setTargetAtTime(vol * 0.6, time + 0.02, decay * 0.25);
masterGain.gain.exponentialRampToValueAtTime(0.001, time + decay);
// Connect to output
masterGain.connect(musicGain);
// Start and stop
osc.start(time);
subOsc.start(time);
osc.stop(time + decay + 0.1);
subOsc.stop(time + decay + 0.1);
}
function scheduleNote(step, time) {
advanceSeqGraduationIfNeeded(step);
const mult = breakbeat.barInstinct.pruneMult || {};
const pMult = (v) => {
const m = mult[v];
return (typeof m === 'number' && isFinite(m)) ? Math.max(0, Math.min(1, m)) : 1;
};
const pClamp = (x) => Math.max(0, Math.min(1, x));
const hit = (v) => {
breakbeat.barOnCount = (breakbeat.barOnCount || 0) + 1;
};
// Get volume multiplier for a voice (0-1), minimum 0.001 to avoid exponentialRamp errors
const vVol = (v) => {
// Check voice ID first (e.g. 'hihat0'), then fall back to type (e.g. 'hihat')
// ID takes priority since that's what the voice panel sliders set
const vol = (seqVolume[v + '0'] !== undefined) ? seqVolume[v + '0'] : seqVolume[v];
const normalized = (typeof vol === 'number') ? vol / 100 : 1;
return normalized < 0.001 ? 0.001 : normalized;
};
// Ghost probability check - sequencer knob should not affect normal gameplay.
// User-painted notes always play; ghost mute/prob only affects algorithmic notes.
// In preview mode, bypass ALL algorithmic processing - play raw patterns.
const inPreview = breakbeat.previewMode;
const inSeq = (typeof isSequencerOpen === 'function') ? isSequencerOpen() : true;
const ghostMute = inSeq ? seqGhostMute : false;
const ghostProb = inPreview ? 100 : (inSeq ? seqGhostProb : 100);
const isUserPainted = (voiceId, s) => {
if (typeof seqUserPainted === 'undefined' || !seqUserPainted) return false;
// Check both voiceId format (e.g. 'kick0-0') and voiceType format (e.g. 'kick-0')
// since notes can be marked as user-painted with either key
const voiceType = voiceId.replace(/0$/, ''); // Remove trailing '0' to get type
return !!(seqUserPainted[`${voiceId}-${s}`] || seqUserPainted[`${voiceType}-${s}`]);
};
const ghostOk = (voiceId) => {
// In preview mode, always play what's in the pattern
if (inPreview) return true;
// User-painted notes always play
if (isUserPainted(voiceId, step)) return true;
// Ghost notes respect mute and probability
return !ghostMute && Math.random() * 100 < ghostProb;
};
// P-lock check for a voice at current step - returns {ok, velMult, pitchOffset, nudge, ratchet}
// In preview mode, skip all p-locks to hear raw kit
const getPlock = (voiceId) => {
if (inPreview) return { ok: true, velMult: 1, pitchOffset: 0, nudge: 0, ratchet: 1 };
if (typeof seqPlocks === 'undefined' || !seqPlocks) return { ok: true, velMult: 1, pitchOffset: 0, nudge: 0, ratchet: 1 };
// Check both voiceId (e.g. 'hihat') and voiceId0 format (e.g. 'hihat0')
const key = `${voiceId}-${step}`;
const key0 = `${voiceId}0-${step}`;
const plock = seqPlocks[key] || seqPlocks[key0];
if (!plock) return { ok: true, velMult: 1, pitchOffset: 0, nudge: 0, ratchet: 1 };
// Probability check
if (plock.prob < 100 && Math.random() * 100 > plock.prob) return { ok: false };
// Velocity 0 means skip
if (plock.vel <= 0) return { ok: false };
return {
ok: true,
velMult: plock.vel / 80,
pitchOffset: plock.pitch || 0,
nudge: plock.nudge || 0,
ratchet: plock.ratchet || 1
};
};
// Mark ghost trigger for flash effect
const markGhost = (voice) => {
if (typeof ghostTriggers !== 'undefined') {
ghostTriggers[`${voice}-${step}`] = Date.now();
}
if (inSeq && seqGraduation && seqGraduation.active && seqGraduation.hits) {
const k = `${voice}-${step}`;
seqGraduation.hits[k] = (seqGraduation.hits[k] || 0) + 1;
}
};
// In preview mode, bypass all algorithmic modifiers (chorus, backbeat, bass offset)
const chorus = inPreview ? false : !!(breakbeat.barInstinct && breakbeat.barInstinct.chorusOn);
// Check if step should be on - backbeat forces certain steps, but must respect ghost mute
// If ghost mute is on and step is not user-painted, backbeat should not force it
// For per-voice motifs: check seqPatterns[voiceId] first, then fall back to breakbeat.patterns[voiceType]
const getFirstVoiceOfType = (type) => {
if (typeof voiceList === 'undefined' || !voiceList) return null;
return voiceList.find(v => v.type === type) || null;
};
const kick0 = getFirstVoiceOfType('kick');
const snare0 = getFirstVoiceOfType('snare');
const hihat0 = getFirstVoiceOfType('hihat');
// Check seqPatterns first (for per-voice motifs), then breakbeat.patterns (for ghost/algorithmic)
// BUT: If ghosts are muted, don't fall back to breakbeat.patterns (use empty array instead)
const kickPattern = (kick0 && seqPatterns && seqPatterns[kick0.id])
? seqPatterns[kick0.id]
: (ghostMute ? [] : (breakbeat.patterns.kick || []));
const snarePattern = (snare0 && seqPatterns && seqPatterns[snare0.id])
? seqPatterns[snare0.id]
: (ghostMute ? [] : (breakbeat.patterns.snare || []));
const hihatPattern = (hihat0 && seqPatterns && seqPatterns[hihat0.id])
? seqPatterns[hihat0.id]
: (ghostMute ? [] : (breakbeat.patterns.hihat || []));
const backbeatForcesKick = (!inPreview && breakbeat.barInstinct.backbeatOn) && (step === 0 || step === 10);
const backbeatForcesSnare = (!inPreview && breakbeat.barInstinct.backbeatOn) && (step === 4 || step === 12);
// Backbeat forces steps from pattern, but still respect ghost mute
// User-painted steps always play regardless of backbeat
// Note: ghost probability is checked later in ghostOk(), not here
const kickStepOn = backbeatForcesKick
? (isUserPainted('kick0', step) || (!ghostMute && !!kickPattern[step]))
: !!kickPattern[step];
const snareStepOn = backbeatForcesSnare
? (isUserPainted('snare0', step) || (!ghostMute && !!snarePattern[step]))
: !!snarePattern[step];
const bassOffset = (!inPreview && breakbeat.barInstinct && Number.isFinite(breakbeat.barInstinct.bassStepOffset))
? ((breakbeat.barInstinct.bassStepOffset % 16) + 16) % 16
: 0;
const bassStep = (step + bassOffset) & 15;
// Helper to play with ratchet (repeat notes within step)
const playWithRatchet = (playFn, baseTime, vol, plock, extraArgs = []) => {
const ratchet = plock.ratchet || 1;
const nudgeOffset = (plock.nudge || 0) / 1000; // Convert ms to seconds
const stepDuration = 60 / breakbeat.bpm / 4; // Duration of one 16th note
for (let r = 0; r < ratchet; r++) {
const ratchetTime = baseTime + nudgeOffset + (r * stepDuration / ratchet);
const ratchetVol = vol * (ratchet > 1 ? (1 - r * 0.15) : 1); // Slight decay on repeats
playFn(ratchetTime, ratchetVol, ...extraArgs);
}
};
// === BREAKDOWN PATTERN DENSITY MULTIPLIERS ===
// Reduce pattern density during breakdown, restore during build
let breakdownDensityMult = 1.0;
let melodicDensityMult = 1.0;
let breakdownGateStutter = false;
if (breakbeat.breakdown && breakbeat.breakdown.active && !inPreview && !inSeq) {
const phase = breakbeat.breakdown.phase;
const intensity = (musicSettings && musicSettings.breakdownIntensity) ? musicSettings.breakdownIntensity : 1.0;
if (phase === 'breakdown') {
// Breakdown: Sparse patterns (40-60% for drums, 30-50% for melodic)
breakdownDensityMult = 0.4 + (Math.random() * 0.2); // 0.4-0.6 for drums
melodicDensityMult = 0.3 + (Math.random() * 0.2); // 0.3-0.5 for melodic
breakdownGateStutter = (step % 4 === 0); // Every 4th step (1/4-note)
} else if (phase === 'build') {
// Build: Gradually restore (70-100%)
const progress = 1.0 - (breakbeat.breakdown.barsRemaining / breakbeat.breakdown.buildBars);
breakdownDensityMult = 0.7 + (progress * 0.3); // 0.7 → 1.0
melodicDensityMult = 0.7 + (progress * 0.3); // 0.7 → 1.0
breakdownGateStutter = (Math.random() < (0.6 + progress * 0.2)); // 60-80% per step
} else if (phase === 'drop') {
// Drop: Full density
breakdownDensityMult = 1.0;
melodicDensityMult = 1.0;
breakdownGateStutter = false;
}
// Apply gate stutter if active
if (breakdownGateStutter && window.breakdownFX) {
const fx = window.breakdownFX;
const stepTime = 60 / breakbeat.bpm / 4;
const stutterTime = time;
const stutterDuration = stepTime * 0.5; // 50% of step
fx.gate.gain.setValueAtTime(0, stutterTime);
fx.gate.gain.linearRampToValueAtTime(1.0, stutterTime + 0.001); // <1ms attack
fx.gate.gain.exponentialRampToValueAtTime(0.001, stutterTime + stutterDuration);
// Reopen gate for next step (unless it's another stutter step)
fx.gate.gain.linearRampToValueAtTime(1.0, stutterTime + stepTime - 0.001);
} else if (window.breakdownFX && (!breakbeat.breakdown || !breakbeat.breakdown.active || breakbeat.breakdown.phase === 'drop')) {
// Ensure gate is open when not in breakdown/build
window.breakdownFX.gate.gain.setValueAtTime(1.0, time);
}
}
// Kick/Snare/HiHat: 83-90% probability (respects ghost mute/prob + p-locks)
// Scale base probability by ghostProb/100 so slider at 100% = full probability
const ghostProbMult = ghostProb / 100;
const kickPlock = getPlock('kick');
const kickBaseProb = (chorus ? 0.96 : (0.83 + Math.random() * 0.07)) * pMult('kick') * ghostProbMult * breakdownDensityMult;
if (kickPlock.ok && voiceAllowedThisStep('kick', step) && kickStepOn && ghostOk('kick0') && Math.random() < pClamp(kickBaseProb)) {
playWithRatchet(playKick, time, 0.4 * vVol('kick') * kickPlock.velMult, kickPlock);
hit('kick');
markGhost('kick');
}
const snarePlock = getPlock('snare');
const snareBaseProb = (chorus ? 0.95 : (0.83 + Math.random() * 0.07)) * pMult('snare') * ghostProbMult * breakdownDensityMult;
if (snarePlock.ok && voiceAllowedThisStep('snare', step) && snareStepOn && ghostOk('snare0') && Math.random() < pClamp(snareBaseProb)) {
playWithRatchet(playSnare, time, 0.35 * vVol('snare') * snarePlock.velMult, snarePlock);
hit('snare');
markGhost('snare');
}
const hihatPlock = getPlock('hihat');
const hihatBaseProb = (chorus ? 0.95 : (0.83 + Math.random() * 0.07)) * pMult('hihat') * ghostProbMult * breakdownDensityMult;
if (hihatPlock.ok && voiceAllowedThisStep('hihat', step) && hihatPattern[step] && ghostOk('hihat0') && Math.random() < pClamp(hihatBaseProb)) {
// Pass step for position-based open/closed logic via extraArgs
// Get hat voice ID (hat0, hat1, etc.) for motif routing
const hatVoiceId = hihat0 ? hihat0.id : 'hat0';
playWithRatchet(playHiHat, time, 0.25 * vVol('hihat') * hihatPlock.velMult, hihatPlock, [1.0, null, step, hatVoiceId]);
hit('hihat');
markGhost('hihat');
}
// Occasional XSID-ish sizzle hat layer (more likely in chorus/rally)
if (voiceAllowedThisStep('hihat', step) && hihatPattern[step] && ghostOk('hihat0')) {
const rally = breakbeat.rallyBarsRemaining > 0;
const sizzleP = chorus ? 0.12 : rally ? 0.10 : 0.05;
if (Math.random() < pClamp(sizzleP * pMult('hihat'))) {
playXSIDSizzleHat(time, chorus ? 0.18 : 0.14);
}
}
const clapPlock = getPlock('clap');
if (clapPlock.ok && voiceAllowedThisStep('clap', step) && breakbeat.patterns.clap[step] && ghostOk('clap0') && Math.random() < pClamp(breakbeat.probability * pMult('clap') * ghostProbMult)) {
playWithRatchet(playClap, time, 0.4 * vVol('clap') * clapPlock.velMult, clapPlock);
hit('clap');
markGhost('clap');
}
// Ghost voices - lower probability, quieter (all respect ghost mute/prob + p-locks)
// Build phase: Extra ghost notes for energy
const buildGhostBoost = (breakbeat.breakdown && breakbeat.breakdown.active && breakbeat.breakdown.phase === 'build' && !inPreview && !inSeq) ? 1.5 : 1.0;
const rimshotPlock = getPlock('rimshot');
const rimshotBaseProb = 0.7 * pMult('rimshot') * ghostProbMult * buildGhostBoost;
if (rimshotPlock.ok && voiceAllowedThisStep('rimshot', step) && breakbeat.patterns.rimshot && breakbeat.patterns.rimshot[step] && ghostOk('rimshot0') && Math.random() < pClamp(rimshotBaseProb)) {
playWithRatchet(playRimshot, time, 0.4 * rimshotPlock.velMult, rimshotPlock);
markGhost('rimshot');
}
const shakerPlock = getPlock('shaker');
const shakerBaseProb = 0.6 * pMult('shaker') * ghostProbMult * buildGhostBoost;
if (shakerPlock.ok && voiceAllowedThisStep('shaker', step) && breakbeat.patterns.shaker && breakbeat.patterns.shaker[step] && ghostOk('shaker0') && Math.random() < pClamp(shakerBaseProb)) {
playWithRatchet(playShaker, time, 0.35 * shakerPlock.velMult, shakerPlock);
markGhost('shaker');
}
const cowbellPlock = getPlock('cowbell');
const cowbellBaseProb = 0.5 * pMult('cowbell') * ghostProbMult * buildGhostBoost;
if (cowbellPlock.ok && voiceAllowedThisStep('cowbell', step) && breakbeat.patterns.cowbell && breakbeat.patterns.cowbell[step] && ghostOk('cowbell0') && Math.random() < pClamp(cowbellBaseProb)) {
// Cowbell Chaos: 1% chance to become a CDP artifact (metallic scrape) - RARE
const chaosLevel = (breakbeat.motif && typeof breakbeat.motif.chaos !== 'undefined') ? breakbeat.motif.chaos : 1.0;
const cdpCooldownOk = !breakbeat.lastCdpStep || (breakbeat.currentStep - breakbeat.lastCdpStep) >= 32; // 2 bars min
if (cdpCooldownOk && Math.random() < (0.01 * chaosLevel)) {
breakbeat.lastCdpStep = breakbeat.currentStep;
// Re-use CDP logic (metallic scrape)
const playTime = time;
const velVar = 1.0;
// Randomize Scrape Length
const isLongScrape = Math.random() < 0.3;
const baseDecay = isLongScrape ? (0.4 + Math.random() * 0.6) : (0.05 + Math.random() * 0.15);
const scrapeCutoff = 1500 + Math.random() * 8000;
const cdpOvr = {
decay: baseDecay,
release: 0.1,
attack: isLongScrape ? 0.05 : 0.01,
cutoff: scrapeCutoff,
reso: 5 + Math.random() * 5
};
// Use XSID Lead to trigger scrape, as it handles overrides well
// (simulating metallic texture via FM/RingMod/Filter)
const baseHigh = 90 + Math.random() * 12;
playXSIDLeadAccent(playTime, baseHigh, 0.12, cdpOvr);
playXSIDLeadAccent(playTime + 0.02, baseHigh + 1.3, 0.10, cdpOvr);
playXSIDLeadAccent(playTime + 0.04, baseHigh - 2.1, 0.08, cdpOvr);
markGhost('cowbell');
} else {
playWithRatchet(playCowbell, time, 0.12 * cowbellPlock.velMult, cowbellPlock);
markGhost('cowbell');
}
}
const tomPlock = getPlock('tom');
const tomBaseProb = 0.6 * pMult('tom') * ghostProbMult * buildGhostBoost;
if (tomPlock.ok && voiceAllowedThisStep('tom', step) && breakbeat.patterns.tom && breakbeat.patterns.tom[step] && ghostOk('tom0') && Math.random() < pClamp(tomBaseProb)) {
playWithRatchet(playTom, time, 0.4 * tomPlock.velMult, tomPlock);
markGhost('tom');
}
const congaPlock = getPlock('conga');
const congaBaseProb = 0.6 * pMult('conga') * ghostProbMult * buildGhostBoost;
if (congaPlock.ok && voiceAllowedThisStep('conga', step) && breakbeat.patterns.conga && breakbeat.patterns.conga[step] && ghostOk('conga0') && Math.random() < pClamp(congaBaseProb)) {
playWithRatchet(playConga, time, 0.4 * congaPlock.velMult, congaPlock);
markGhost('conga');
}
const ridePlock = getPlock('ride');
const rideBaseProb = 0.5 * pMult('ride') * ghostProbMult * buildGhostBoost;
if (ridePlock.ok && voiceAllowedThisStep('ride', step) && breakbeat.patterns.ride && breakbeat.patterns.ride[step] && ghostOk('ride0') && Math.random() < pClamp(rideBaseProb)) {
// Ride Chaos: 1% chance to become a CDP artifact - RARE
const chaosLevel = (breakbeat.motif && typeof breakbeat.motif.chaos !== 'undefined') ? breakbeat.motif.chaos : 1.0;
const cdpCooldownOk = !breakbeat.lastCdpStep || (breakbeat.currentStep - breakbeat.lastCdpStep) >= 32; // 2 bars min
if (cdpCooldownOk && Math.random() < (0.01 * chaosLevel)) {
breakbeat.lastCdpStep = breakbeat.currentStep;
// CDP Rubbing/Scrape logic
const playTime = time;
const isLongScrape = Math.random() < 0.3;
const baseDecay = isLongScrape ? (0.4 + Math.random() * 0.6) : (0.05 + Math.random() * 0.15);
const scrapeCutoff = 1500 + Math.random() * 8000;
const cdpOvr = {
decay: baseDecay,
release: 0.1,
attack: isLongScrape ? 0.05 : 0.01,
cutoff: scrapeCutoff,
reso: 5 + Math.random() * 5
};
// Use XSID Lead to trigger scrape
const baseHigh = 90 + Math.random() * 12;
playXSIDLeadAccent(playTime, baseHigh, 0.10, cdpOvr);
playXSIDLeadAccent(playTime + 0.02, baseHigh + 1.3, 0.09, cdpOvr);
playXSIDLeadAccent(playTime + 0.04, baseHigh - 2.1, 0.07, cdpOvr);
markGhost('ride');
} else {
playWithRatchet(playRide, time, 0.18 * ridePlock.velMult, ridePlock);
markGhost('ride');
}
}
// SID Bass - plays note value from pattern (66% prob, scaled by ghostProb)
const bassPlock = getPlock('bass');
const bassBaseProb = 0.66 * pMult('bass') * ghostProbMult * melodicDensityMult;
if (bassPlock.ok && voiceAllowedThisStep('bass', step) && breakbeat.patterns.bass[bassStep] && ghostOk('bass0') && Math.random() < pClamp(bassBaseProb)) {
breakbeat.barHits.bass = (breakbeat.barHits.bass || 0) + 1;
if (breakbeat.barStepHits && breakbeat.barStepHits.bass) breakbeat.barStepHits.bass[step] = 1;
const octShift = (breakbeat.octaveShift || 0) * 12;
const bassNote = breakbeat.patterns.bass[bassStep] + octShift + bassPlock.pitchOffset;
const bassVol = 0.25 * vVol('bass') * bassPlock.velMult;
const bassRatchet = (bassPlock.ratchet > 1) ? bassPlock.ratchet : (breakbeat.patterns.bassRatch[bassStep] || 1);
const bass0 = getFirstVoiceOfType('bass');
const bassVoiceId = bass0 ? bass0.id : 'bass0';
playWithRatchet((t, v) => playSmartBass(t, bassNote, v, bassVoiceId), time, bassVol, { ...bassPlock, ratchet: bassRatchet });
hit('bass');
markGhost('bass');
}
// Rare XSID-ish bass accent (5-10% chance)
if (voiceAllowedThisStep('xsidBass', step) && breakbeat.patterns.bass[bassStep] && ghostOk('bass0') && Math.random() < pClamp(0.07 * pMult('xsidBass'))) {
playXSIDBassAccent(time, breakbeat.patterns.bass[bassStep], 0.34);
}
// Lead synth - randomly choose FM pad or Chip lead for variety
const padPlock = getPlock('pad');
const padBaseProb = (0.3 + Math.random() * 0.2) * pMult('pad') * ghostProbMult * melodicDensityMult;
if (padPlock.ok && voiceAllowedThisStep('pad', step) && breakbeat.patterns.pad[step] && ghostOk('pad0') && Math.random() < pClamp(padBaseProb)) {
breakbeat.barHits.pad = (breakbeat.barHits.pad || 0) + 1;
const octShift = (breakbeat.octaveShift || 0) * 12;
const padNote = breakbeat.patterns.pad[step] + octShift + padPlock.pitchOffset;
// Sequencer mode: reduce pad volume by 30% (multiply by 0.7)
const seqVolReduction = inSeq ? 0.7 : 1.0; // 70% volume in sequencer (30% reduction), 100% otherwise
const padVol = 0.10 * vVol('pad') * padPlock.velMult * seqVolReduction;
// 50/50 between FM stab and chip PWM lead - with ratchet support
const padPlayFn = breakbeat.leadType === 'chip'
? (t, v) => playChipLead(t, padNote, v)
: (t, v) => playFMPad(t, padNote, v);
// === APHEX PAD HUMANIZATION ===
// Pads need different treatment: Swells, Stereo Width, Detuning
const isAphexPad = Math.random() < 0.4; // 40% of pads get the treatment
if (isAphexPad) {
// 1. Slow Attack Swell (Ambient)
const swellOvr = {
decay: 0.8 + Math.random() * 0.5, // Long decay
attack: 0.1 + Math.random() * 0.2, // Slow attack
cutoff: 800 + Math.random() * 400, // Darker filter
reso: 1.5 // Gentle resonance
};
// 2. Micro-timing (Lazy/Late)
const padJitter = 0.02 + Math.random() * 0.03; // Always slightly late (20-50ms)
// 3. Stereo Spread (Double trigger)
const leftTime = time + padJitter;
const rightTime = time + padJitter + 0.015; // 15ms Haas effect
// Play Left (Slightly detuned flat)
// We can't easily pan here without refactoring play functions to return nodes,
// so we simulate width by detuning the second hit significantly.
playWithRatchet(padPlayFn, leftTime, padVol * 0.6, padPlock);
// Play Right (Slightly detuned sharp, different timbre if possible)
// We pass a slightly modified note for chorus effect
// Note: playWithRatchet doesn't support pitch bend args easily, so we just trigger standard
// but relying on the natural oscillator drift of the engine.
// Actually, simpler approach for now: Just one "Juicy" hit with long release
// We will rely on the "swellOvr" we just defined.
// Since playWithRatchet expects a simple fn, we wrap it.
const aphexPadFn = (t, v) => {
if (breakbeat.leadType === 'chip') {
playChipLead(t, padNote, v, false, swellOvr);
} else {
playFMPad(t, padNote, v, true, swellOvr); // isMotif=true triggers the 'beautiful' mode in FMPad
}
};
playWithRatchet(aphexPadFn, time + padJitter, padVol * 0.8, padPlock);
} else {
// Standard crisp playback
playWithRatchet(padPlayFn, time, padVol, padPlock);
}
hit('pad');
markGhost('pad');
}
const accentPadNote = breakbeat.patterns.pad[step]
|| ((step % 4 === 0 && Math.random() < 0.55)
? (((breakbeat.harmony && breakbeat.harmony.mode === 'minor') ? breakbeat.padScaleMinor : breakbeat.padScale)[Math.floor(Math.random() * 8)])
: 0);
// Emergent motif accents: short bar-level melodic fragments from counter-rhythm
const motifNote = (breakbeat.motif && breakbeat.motif.notes) ? breakbeat.motif.notes[step] : 0;
const motifVoice = (breakbeat.motif && breakbeat.motif.voice) ? breakbeat.motif.voice : 'xsidLead';
const motifCadence = !!(breakbeat.motif && breakbeat.motif.isCadence);
const motifP = motifCadence ? 0.28 : 0.42;
if (motifNote && voiceAllowedThisStep(motifVoice, step) && ghostOk('lead0') && Math.random() < pClamp(motifP * pMult(motifVoice))) {
// === APHEX MOTIF HUMANIZATION ===
// 1. Micro-timing jitter: ±10ms (organic feel)
const tJitter = (Math.random() - 0.5) * 0.020;
const playTime = time + tJitter;
// 2. Micro-tuning: ±10 cents (0.1 semitone) - analog instability
const nDetune = (Math.random() - 0.5) * 0.2;
const playNote = motifNote + nDetune;
// 3. Velocity humanization: ±20% - expressive dynamics
const velVar = 0.8 + Math.random() * 0.4;
// Helper to trigger the specific voice
const triggerMotif = (t, n, v, ovr = {}) => {
if (motifVoice === 'fmWhammy') {
playFMPitchWhammyLead(t, n, v * 0.300, ovr);
} else if (motifVoice === 'fmBrass') {
playFMBrassStab(t, n, v * 0.10, ovr);
} else if (motifVoice === 'chipLead') {
// Add emergent spice to chip lead (prevents "samey-ness")
const chipSpice = { ...ovr };
// 1. PWM Sweep (Phasing check) - 25% chance
if (Math.random() < 0.25) chipSpice.pwmSweep = true;
// 2. Portamento/Slide (Slidewhistle-ish) - 15% chance
if (Math.random() < 0.15) {
chipSpice.slideAmount = (Math.random() < 0.5 ? 5 : 12) * (Math.random() < 0.5 ? 1 : -1);
chipSpice.slideDuration = 0.1 + Math.random() * 0.1;
}
playChipLead(t, n, v * 0.10, true, chipSpice);
} else if (motifVoice === 'xpoly') {
playXPolyLead(t, n, v * 0.06, ovr);
} else if (motifVoice === 'xwave') {
playXWaveLead(t, n, v * 0.14, ovr);
} else if (motifVoice === 'fmPad') {
playFMPad(t, n, v * 0.12, true, ovr);
} else if (motifVoice === 'marsLead') {
playMarsLead(t, n, v * 0.18, ovr);
} else if (motifVoice === 'acid') {
play303(t, n, v * 0.20, ovr);
} else {
playXSIDLeadAccent(t, n, v * 0.20, ovr);
}
};
// Chaos Level Control (from UI or default)
const chaosLevel = (breakbeat.motif && typeof breakbeat.motif.chaos !== 'undefined') ? breakbeat.motif.chaos : 1.0;
// Voice-specific scaler: Cleaner voices for XWave and Whammy to feature vocal-like timbre
let voiceChaosScale = 1.0;
if (motifVoice === 'xwave' || motifVoice === 'fmWhammy') {
voiceChaosScale = 0.45; // ~55% reduction in chaos for these
}
// Tracker Logic: "Cadence Drills"
// In trackers, fills usually happen at the end of a bar (steps 12-15).
// If we are in the "Cadence Zone", we BOOST the glitch probability significantly.
const isCadenceZone = (step % 16) >= 13; // Last 3 steps of a bar
const cadenceBoost = isCadenceZone ? 3.0 : 1.0;
const effChaos = chaosLevel * voiceChaosScale * cadenceBoost;
// PHASE 2 & 3: TEXTURAL CHAOS
// 4. Granular Glitch (Retrigger): 12% chance * effective chaos
if (Math.random() < (0.12 * effChaos)) {
// If in cadence zone, make grains faster/tighter (Drill Effect)
const isDrill = isCadenceZone && Math.random() < 0.7;
const grains = isDrill ? (4 + Math.floor(Math.random() * 4)) : (2 + Math.floor(Math.random() * 4));
const grainDur = isDrill ? (0.015 + Math.random() * 0.01) : (0.03 + Math.random() * 0.04);
// Variant: sometimes tight/plucky glitches (50% of glitches)
// For drills, we almost ALWAYS want plucky/short envelopes
const isPlucky = isDrill || Math.random() < 0.5;
const glitchOvr = isPlucky ? { decay: 0.05, reso: 8, cutoff: 4000 } : {};
for (let g = 0; g < grains; g++) {
// Decaying velocity for grains
triggerMotif(playTime + (g * grainDur), playNote, velVar * (1 - g * 0.15), glitchOvr);
}
hit(motifVoice); // Visually trigger once
return; // Skip main note
}
// 5. Probability Cascade (Echo/Delay): 8% chance * effective chaos
if (Math.random() < (0.08 * effChaos)) {
let prob = 1.0;
let echoTime = playTime;
const echoStep = 0.12 + Math.random() * 0.05; // ~8th note delay
// Variant: sometimes "dark dub" echoes (low cutoff)
const isDub = Math.random() < 0.4;
const dubOvr = isDub ? { cutoff: 600, decay: 0.15 } : {};
// Original Note (Clean or modified)
triggerMotif(playTime, playNote, velVar);
// Cascading Echoes (up to 4)
for (let i = 1; i <= 4; i++) {
prob *= 0.6; // Rapid decay
if (Math.random() < 0.7) { // 70% chance per echo stage
echoTime += echoStep;
triggerMotif(echoTime, playNote, velVar * prob, dubOvr);
}
}
hit(motifVoice);
return;
}
// 6. Harmonic Blur (Spectral Smear): 5% chance * effective chaos
if (Math.random() < (0.05 * effChaos)) {
// ... (existing harmonic blur logic) ...
const partials = [1, 2, 3, 5]; // Fundamental, Octave, Fifth, Major 3rd (ish)
const blurSpread = 0.005; // 5ms spread (Haas)
partials.forEach((p, i) => {
const harmonicTime = playTime + (i * blurSpread);
const harmOvr = {
decay: 0.1 / p, // Higher partials decay faster
cutoff: 8000,
reso: 2
};
let noteOffset = 0;
if (p === 2) noteOffset = 12;
if (p === 3) noteOffset = 19;
if (p === 5) noteOffset = 28;
triggerMotif(harmonicTime, playNote + noteOffset, velVar * (0.6 / p), harmOvr);
});
hit(motifVoice);
return;
}
// 7. CDP Artifact (Metallic Noise Scrape): 1% chance (VERY RARE Texture)
// Replaces the note entirely with a non-tonal texture
const cdpCooldownOk = !breakbeat.lastCdpStep || (breakbeat.currentStep - breakbeat.lastCdpStep) >= 32; // 2 bars min
if (cdpCooldownOk && Math.random() < (0.01 * effChaos)) {
breakbeat.lastCdpStep = breakbeat.currentStep;
// Randomize Scrape Length: mostly short, sometimes long tear
const isLongScrape = Math.random() < 0.3;
const baseDecay = isLongScrape ? (0.4 + Math.random() * 0.6) : (0.05 + Math.random() * 0.15);
// Variable brightness (Dark Rub vs Bright Scrape)
const scrapeCutoff = 1500 + Math.random() * 8000;
// Simulate CDP "Rubbing" effect
const cdpOvr = {
decay: baseDecay,
release: 0.1, // slightly longer tail
attack: isLongScrape ? 0.05 : 0.01, // softer attack for long scrapes
cutoff: scrapeCutoff,
reso: 5 + Math.random() * 5 // varying resonance
};
// Play a cluster of dissonant high notes to simulate noise
// CAP GAIN: CDP artifacts can be piercing, so we reduce the velVar multiplier significantly
// Original was implicit in playXSIDLeadAccent (usually ~0.2), we force it lower here.
const cdpVol = velVar * 0.12;
for (let i = 0; i < 4; i++) {
// ...existing cluster logic...
playXSIDLeadAccent(playTime + (Math.random() * 0.02), 80 + Math.random() * 20, cdpVol, cdpOvr);
}
const baseHigh = playNote + 36 + Math.random() * 12; // +3 octaves
triggerMotif(playTime, baseHigh, velVar * 0.5, cdpOvr);
triggerMotif(playTime + 0.02, baseHigh + 1.3, velVar * 0.4, cdpOvr);
triggerMotif(playTime + 0.04, baseHigh - 2.1, velVar * 0.3, cdpOvr);
hit(motifVoice);
return;
}
// 8. Reverse Note (Envelope Flip): 4% chance * effective chaos
// Plays the note with a long attack and sharp cut, mimicking a reversed tape
if (Math.random() < (0.04 * effChaos)) {
const revDur = 0.3 + Math.random() * 0.3; // 300-600ms reverse swell
const revOvr = {
attack: revDur, // Use attack for the swell
decay: 0.01,
cutoff: 1000, // Start dark? No, filter env usually opens. Let's keep standard filter but slow amp
reso: 5
};
// Trigger ~150ms early if possible? Hard in real-time.
// We just play it late effectively, but it sounds like a suck.
triggerMotif(playTime, playNote, velVar * 0.9, revOvr);
hit(motifVoice);
return;
}
// 9. SID Arpeggio (C64 CHORD): 10% chance * effective chaos (Only for Chip/SID/XSID)
// Rapid 0-4-7-12 pitch cycle on a single voice channel
// We implement this by specific override flag if the voice supports it, or just emulating it
if ((motifVoice === 'chipLead' || motifVoice === 'xsidLead') && Math.random() < (0.10 * effChaos)) {
// We need to pass a "sidArp" flag.
// Since we haven't refactored all play functions to take a custom flag,
// we will use the 'overrides' object to pass a 'special' param if we can,
// or hack it by triggering 4 very fast notes?
// Triggering 4 fast notes is easier and more robust across engines.
const arpSpeed = 0.035; // 35ms per step (very fast)
const offsets = [0, 4, 7, 12, 0, 4, 7, 12]; // Major arp
// If minor scale?
const isMinor = (breakbeat.harmony && breakbeat.harmony.mode === 'minor');
const arpOffsets = isMinor ? [0, 3, 7, 12, 0, 3, 7, 12] : offsets;
const arpOvr = { decay: 0.04, sustain: 0, release: 0.01 }; // Staccato
for (let i = 0; i < 6; i++) { // 6 steps of arp
triggerMotif(playTime + (i * arpSpeed), playNote + arpOffsets[i], velVar * 0.9, arpOvr);
}
hit(motifVoice);
return;
}
// Standard Playback (if no glitches)
triggerMotif(playTime, playNote, velVar);
hit(motifVoice);
// If motif fires this step, don't also force the generic accent note logic.
return;
}
if (voiceAllowedThisStep('fmWhammy', step) && accentPadNote && ghostOk('lead0') && Math.random() < pClamp(0.12 * pMult('fmWhammy'))) {
playFMPitchWhammyLead(time, accentPadNote, 0.360);
}
if (voiceAllowedThisStep('fmBrass', step) && accentPadNote && ghostOk('lead0') && Math.random() < pClamp(0.10 * pMult('fmBrass'))) {
playFMBrassStab(time, accentPadNote, 0.12);
}
if (voiceAllowedThisStep('xpoly', step) && breakbeat.patterns.pad[step] && ghostOk('pad0') && Math.random() < pClamp(0.15 * pMult('xpoly'))) {
playXPolyLead(time, breakbeat.patterns.pad[step], 0.06);
}
// Rare XSID-ish lead accent (5-10% chance)
if (voiceAllowedThisStep('xsidLead', step) && breakbeat.patterns.pad[step] && ghostOk('lead0') && Math.random() < pClamp(0.07 * pMult('xsidLead'))) {
playXSIDLeadAccent(time, breakbeat.patterns.pad[step], 0.22);
}
// === TRANSFORMER SCRATCHES (Build Phase) ===
// Trigger on bar boundaries (step 0) during build phase
if (step === 0 && breakbeat.breakdown && breakbeat.breakdown.active && breakbeat.breakdown.phase === 'build' && !inPreview && !inSeq) {
const barStartTime = time;
const barsRemaining = breakbeat.breakdown.barsRemaining;
executeTransformerScratches(barStartTime, Math.min(barsRemaining, 1)); // Trigger for current bar
}
// === EXTENDED VOICES SCHEDULING (with "instincts") ===
// Only in game/music settings mode (not sequencer/preview)
// In game mode, bypass ghost checks (ghostProb = 100) and use prevalence directly
if (!inPreview && !inSeq) {
// Helper to quantize notes to current scale
const quantizeNoteToScale = (note, scale) => {
if (!note || !Array.isArray(scale) || scale.length === 0) return note;
let best = scale[0];
let bestD = Infinity;
for (let i = 0; i < scale.length; i++) {
const base = scale[i];
for (const sh of [-12, 0, 12]) {
const cand = base + sh;
const d = Math.abs(cand - note);
if (d < bestD) {
bestD = d;
best = cand;
}
}
}
return best;
};
// Get current scale based on harmony mode (supports all scales)
const harmonyMode = (breakbeat.harmony && breakbeat.harmony.mode) ? breakbeat.harmony.mode : 'major';
const root = 36; // C2
const padRoot = 60; // C4
const intervals = SCALE_INTERVALS[harmonyMode] || SCALE_INTERVALS.major;
const bassScale = intervals.map(i => root + i);
const padScale = intervals.map(i => padRoot + i);
// These play from breakbeat.patterns, respecting prevalence settings
// Prevalence is the main gate - no additional ghost probability in game mode
const acidPrevalence = (musicSettings && musicSettings.acidPrevalence) ? musicSettings.acidPrevalence : 0.35;
const popPrevalence = (musicSettings && musicSettings.popPrevalence) ? musicSettings.popPrevalence : 0.25;
const stabPrevalence = (musicSettings && musicSettings.stabPrevalence) ? musicSettings.stabPrevalence : 0.30;
const whammyPrevalence = (musicSettings && musicSettings.whammyPrevalence) ? musicSettings.whammyPrevalence : 0.28;
const xpolyPrevalence = (musicSettings && musicSettings.xpolyPrevalence) ? musicSettings.xpolyPrevalence : 0.30;
const squarepusherPrevalence = (musicSettings && musicSettings.squarepusherPrevalence) ? musicSettings.squarepusherPrevalence : 0.25;
const uziqPrevalence = (musicSettings && musicSettings.uziqPrevalence) ? musicSettings.uziqPrevalence : 0.20;
// ACID (TB-303): Bass-like, follows bass patterns or creates counter-melody
// Instinct: Play when bass plays, or from own pattern
// Special: Low prob (8%) volume spike to match bass when emergent melody is active
if (breakbeat.patterns.acid && breakbeat.patterns.acid[step] && Math.random() < acidPrevalence) {
const acidNote = breakbeat.patterns.acid[step];
// 60% chance to follow bass, 40% chance to play own pattern
const followBass = Math.random() < 0.6 && breakbeat.patterns.bass && breakbeat.patterns.bass[bassStep];
let playNote = followBass ? breakbeat.patterns.bass[bassStep] : acidNote;
// Quantize to bass scale for musicality
if (playNote && playNote > 0) {
playNote = quantizeNoteToScale(playNote, bassScale);
}
if (playNote && playNote > 0 && voiceAllowedThisStep('acid', step)) {
// Check for emergent melody activity (motif playing)
const motifActive = breakbeat.motif && breakbeat.motif.notes && breakbeat.motif.notes[step];
const volumeSpike = motifActive && (Math.random() < 0.08); // 8% chance to match bass volume
const acidVol = volumeSpike ? 0.25 : 0.1875; // Spike to bass level or normal (25% lower)
play303(time, playNote, acidVol);
}
}
// POP (Drum Pop): Percussion accent, accents snare/clap hits
// Instinct: Primarily accents snare/clap, can also play from own pattern
const snareOrClap = snareStepOn || (breakbeat.patterns.clap && breakbeat.patterns.clap[step]);
const hasPopPattern = breakbeat.patterns.pop && breakbeat.patterns.pop[step];
// Primary: Accent snare/clap hits (high probability when they play)
if (snareOrClap && Math.random() < popPrevalence) {
if (voiceAllowedThisStep('pop', step)) {
playPop(time, 0.2, { ratchetChance: 0.12 });
}
}
// Secondary: Play from own pattern (lower probability)
else if (hasPopPattern && Math.random() < (popPrevalence * 0.5)) {
if (voiceAllowedThisStep('pop', step)) {
playPop(time, 0.2, { ratchetChance: 0.12 });
}
}
// STAB (FM Brass): Melodic accent, accents pad/lead OR plays own pattern
// Instinct: Accent pad/lead, or play from own pattern
if (breakbeat.patterns.stab && breakbeat.patterns.stab[step] && Math.random() < stabPrevalence) {
const stabNote = breakbeat.patterns.stab[step];
// 50% chance to accent pad, 50% chance to play own pattern
const accentPad = accentPadNote && (Math.random() < 0.5);
let playNote = accentPad ? accentPadNote : stabNote;
// Quantize to pad scale for musicality
if (playNote && playNote > 0) {
playNote = quantizeNoteToScale(playNote, padScale);
}
if (playNote && playNote > 0 && voiceAllowedThisStep('stab', step)) {
playFMBrassStab(time, playNote, 0.15);
}
}
// WHAMMY (FM Pitch Whammy): Melodic accent, accents pad/lead OR plays own pattern
// Instinct: Accent pad/lead, or play from own pattern
if (breakbeat.patterns.whammy && breakbeat.patterns.whammy[step] && Math.random() < whammyPrevalence) {
const whammyNote = breakbeat.patterns.whammy[step];
// 50% chance to accent pad, 50% chance to play own pattern
const accentPad = accentPadNote && (Math.random() < 0.5);
let playNote = accentPad ? accentPadNote : whammyNote;
// Quantize to pad scale for musicality
if (playNote && playNote > 0) {
playNote = quantizeNoteToScale(playNote, padScale);
}
if (playNote && playNote > 0 && voiceAllowedThisStep('whammy', step)) {
playFMPitchWhammyLead(time, playNote, 0.35);
}
}
// XPOLY (XPoly Lead): Melodic accent, accents pad/lead OR plays own pattern
// Instinct: Accent pad/lead, or play from own pattern
if (breakbeat.patterns.xpoly && breakbeat.patterns.xpoly[step] && Math.random() < xpolyPrevalence) {
const xpolyNote = breakbeat.patterns.xpoly[step];
// 50% chance to accent pad, 50% chance to play own pattern
const accentPad = accentPadNote && (Math.random() < 0.5);
let playNote = accentPad ? accentPadNote : xpolyNote;
// Quantize to pad scale for musicality
if (playNote && playNote > 0) {
playNote = quantizeNoteToScale(playNote, padScale);
}
if (playNote && playNote > 0 && voiceAllowedThisStep('xpoly', step)) {
playXPolyLead(time, playNote, 0.08);
}
}
// SQUAREPUSHER (Squarepusher Bass): Bass-like melodic, can follow bass or play own pattern
// Instinct: Similar to acid - can follow bass or create counter-melody
if (breakbeat.patterns.squarepusher && breakbeat.patterns.squarepusher[step] && Math.random() < squarepusherPrevalence) {
const squarepusherNote = breakbeat.patterns.squarepusher[step];
// 50% chance to follow bass, 50% chance to play own pattern
const followBass = Math.random() < 0.5 && breakbeat.patterns.bass && breakbeat.patterns.bass[bassStep];
let playNote = followBass ? breakbeat.patterns.bass[bassStep] : squarepusherNote;
// Quantize to bass scale for musicality
if (playNote && playNote > 0) {
playNote = quantizeNoteToScale(playNote, bassScale);
}
if (playNote && playNote > 0 && voiceAllowedThisStep('squarepusher', step)) {
// Optional: Trigger harmonics very rarely (3% chance, and only if main note plays)
const triggerHarm = Math.random() < 0.3;
if (triggerHarm) {
const fret = Math.floor(Math.random() * 4); // 0-3 for 3rd/5th/7th/9th
triggerSquarepusherHarmonic(time, fret, 0.4);
}
// Very quiet - only 0.8% volume, and only when sparse (not every note)
const shouldPlay = Math.random() < 0.25; // Only 25% of pattern notes actually play
if (shouldPlay) {
playSquarepusherBass(time, playNote, 0.008); // 0.8% volume - very subtle layer
}
}
}
// UZIQ (µ-Ziq Melody): Granular + dual arp texture layer (that cool "wikky wikky" sound!)
// Instinct: Sparse textural layer, granular clouds + offset arps
// Increased prevalence check to make it more audible
if (breakbeat.patterns.uziq && breakbeat.patterns.uziq[step] && Math.random() < (uziqPrevalence * 1.5)) {
const uziqNote = breakbeat.patterns.uziq[step];
// 40% chance to accent pad, 60% chance to play own pattern
const accentPad = accentPadNote && (Math.random() < 0.4);
let playNote = accentPad ? accentPadNote : uziqNote;
// Quantize to pad scale for musicality
if (playNote && playNote > 0) {
playNote = quantizeNoteToScale(playNote, padScale);
}
if (playNote && playNote > 0 && voiceAllowedThisStep('uziq', step)) {
// Granular + dual arp
// Increased volume to make it more audible
playUZiqMelody(time, playNote, 0.15, { duration: 0.2, enableArp: true });
}
}
}
// Play ALL voices from voiceList using seqPatterns
if (typeof voiceList !== 'undefined' && voiceList && typeof seqPatterns !== 'undefined' && seqPatterns) {
voiceList.forEach((v, idx) => {
if (!v || !v.id) return;
// Check seqPatterns for this voice - fallback to type-based if ID-based doesn't exist
const pattern = seqPatterns[v.id] || seqPatterns[v.type] || (breakbeat.patterns && breakbeat.patterns[v.type]);
if (!pattern || !pattern[step]) return;
try {
if (!voiceAllowedThisStep(v.id, step)) return;
} catch (e) { return; }
// Check if user-painted - if not, respect ghost mute
// Use voiceId for paintKey to match how we track user-painted notes
if (!ghostOk(v.id)) return;
// Check p-lock for this voice+step (check both id and type format)
const plockKeyId = `${v.id}-${step}`;
const plockKeyType = `${v.type}-${step}`;
const plock = (typeof seqPlocks !== 'undefined') ? (seqPlocks[plockKeyId] || seqPlocks[plockKeyType]) : null;
// P-lock probability check (default 100%)
if (plock && plock.prob < 100 && Math.random() * 100 > plock.prob) return;
// P-lock velocity 0 = skip note
if (plock && plock.vel <= 0) return;
// P-lock velocity multiplier (default 80 = 1.0x, range 0-100)
const velMult = plock ? (plock.vel / 80) : 1.0;
const vol = vVol(v.id) * velMult;
// P-lock pitch offset (for melodic voices)
const pitchOffset = plock ? (plock.pitch || 0) : 0;
const noteVal = pattern[step];
const pitchedNote = (typeof noteVal === 'number' && noteVal > 1) ? noteVal + pitchOffset : noteVal;
// P-lock nudge and ratchet
const ratchet = plock ? (plock.ratchet || 1) : 1;
const nudgeOffset = plock ? ((plock.nudge || 0) / 1000) : 0;
const stepDuration = 60 / breakbeat.bpm / 4;
// For drums, noteVal encodes pitch multiplier (0.5-1.5 range)
// For melodic, noteVal is MIDI note number
const drumPitch = (noteVal > 0 && noteVal <= 2) ? noteVal : 1.0;
// Play with ratchet support
for (let r = 0; r < ratchet; r++) {
const ratchetTime = time + nudgeOffset + (r * stepDuration / ratchet);
const ratchetVol = vol * (ratchet > 1 ? (1 - r * 0.15) : 1);
if (v.type === 'kick') playKick(ratchetTime, 0.5 * ratchetVol, drumPitch);
else if (v.type === 'snare') playSnare(ratchetTime, 0.45 * ratchetVol, drumPitch);
else if (v.type === 'hihat') playHiHat(ratchetTime, 0.35 * ratchetVol, drumPitch, null, -1, v.id || 'hat0');
else if (v.type === 'clap') playClap(ratchetTime, 0.5 * ratchetVol);
else if (v.type === 'perc') playRimshot(ratchetTime, 0.4 * ratchetVol);
else if (v.type === 'rimshot') playRimshot(ratchetTime, 0.4 * ratchetVol);
else if (v.type === 'shaker') playShaker(ratchetTime, 0.35 * ratchetVol);
else if (v.type === 'cowbell') playCowbell(ratchetTime, 0.4 * ratchetVol);
else if (v.type === 'tom') playTom(ratchetTime, 0.45 * ratchetVol, drumPitch);
else if (v.type === 'conga') playConga(ratchetTime, 0.4 * ratchetVol, drumPitch);
else if (v.type === 'bass') playSmartBass(ratchetTime, pitchedNote || 36, 0.25 * ratchetVol, v.id || 'bass0');
else if (v.type === 'pad') playFMPad(ratchetTime, pitchedNote || 60, 0.15 * ratchetVol);
else if (v.type === 'lead') {
// Use selected lead voice from dropdown
const leadVoice = (typeof seqMotifVoice !== 'undefined' && seqMotifVoice) ? seqMotifVoice :
(musicSettings && musicSettings.motifVoice) ? musicSettings.motifVoice : getRandomLeadVoice();
// Get gain multiplier from leadParams
const getGain = (key) => (leadParams && leadParams[key] && leadParams[key].gain) ? leadParams[key].gain / 100 : 1.0;
if (leadVoice === 'xwave') playXWaveLead(ratchetTime, pitchedNote || 60, 0.14 * ratchetVol);
else if (leadVoice === 'sidLead' || leadVoice === 'xsidLead') playXSIDLeadAccent(ratchetTime, pitchedNote || 60, 0.2 * ratchetVol * getGain('sid'));
else if (leadVoice === 'fmWhammy') playFMPitchWhammyLead(ratchetTime, pitchedNote || 60, 0.3 * ratchetVol * getGain('fm'));
else if (leadVoice === 'fmPad') playFMPad(ratchetTime, pitchedNote || 60, 0.12 * ratchetVol * getGain('pad'), true);
else if (leadVoice === 'fmBrass') playFMBrassStab(ratchetTime, pitchedNote || 60, 0.14 * ratchetVol * getGain('brass'));
else if (leadVoice === 'xpoly') playXPolyLead(ratchetTime, pitchedNote || 60, 0.06 * ratchetVol * getGain('poly'));
else if (leadVoice === 'marsLead') playMarsLead(ratchetTime, pitchedNote || 60, 0.15 * ratchetVol);
else if (leadVoice === 'acid') play303(ratchetTime, pitchedNote || 36, 0.20 * ratchetVol);
else if (leadVoice === 'chipLead') playChipLead(ratchetTime, pitchedNote || 60, 0.1 * ratchetVol * getGain('chip'), true);
else if (leadVoice === 'xwaveX') playXWaveX(ratchetTime, pitchedNote || 60, 0.14 * ratchetVol);
else if (leadVoice === 'xwave3d') playXWave3D(ratchetTime, pitchedNote || 60, 0.14 * ratchetVol);
else playXWaveLead(ratchetTime, pitchedNote || 60, 0.14 * ratchetVol); // Fallback to self-evolving GB Wave
}
else if (v.type === 'stab') playFMBrassStab(ratchetTime, pitchedNote || 60, 0.15 * ratchetVol);
else if (v.type === 'whammy') playFMPitchWhammyLead(ratchetTime, pitchedNote || 60, 0.35 * ratchetVol);
else if (v.type === 'xpoly') playXPolyLead(ratchetTime, pitchedNote || 60, 0.08 * ratchetVol);
else if (v.type === 'acid') play303(ratchetTime, pitchedNote || 36, 0.1875 * ratchetVol); // 25% lower than bass (0.25 * 0.75)
else if (v.type === 'pop') playPop(ratchetTime, 0.2 * ratchetVol, { ratchetChance: 0.12 });
}
});
}
}
// Track bars for evolve timing
let evolveBarCounter = 0;
// === BJORKLUND'S EUCLIDEAN ALGORITHM (from $1010) ===
// Distributes N pulses across K steps as evenly as possible
function bjorklund(steps, pulses, rotation = 0) {
if (pulses >= steps) return Array(steps).fill(1);
if (pulses <= 0) return Array(steps).fill(0);
let pattern = [];
let counts = [];
let remainders = [];
let divisor = steps - pulses;
remainders.push(pulses);
let level = 0;
while (remainders[level] > 1) {
counts.push(Math.floor(divisor / remainders[level]));
remainders.push(divisor % remainders[level]);
divisor = remainders[level];
level++;
}
counts.push(divisor);
function build(lvl) {
if (lvl === -1) pattern.push(0);
else if (lvl === -2) pattern.push(1);
else {
for (let i = 0; i < counts[lvl]; i++) build(lvl - 1);
if (remainders[lvl] !== 0) build(lvl - 2);
}
}
build(level);
// Rotate pattern
const rot = ((rotation % steps) + steps) % steps;
return [...pattern.slice(rot), ...pattern.slice(0, rot)];
}
// Generate euclidean bass pattern with scale notes
function euclidBass(pulses, rotation = 0) {
const scale = breakbeat.bassScale;
const rhythm = bjorklund(16, pulses, rotation);
return rhythm.map((hit, i) => {
if (!hit) return 0;
// Pick notes: root on beat 1, then cycle through scale
if (i === 0) return scale[0]; // Root
return scale[Math.floor(Math.random() * scale.length)];
});
}
// === EVOLVE SYSTEM (inspired by $1010) ===
// Mutations: shift, addRm, swap, octave (for bass), euclid
function evolvePatterns(intensity = 1, voiceList = null) {
const scale = (breakbeat.harmony && breakbeat.harmony.mode === 'minor') ? breakbeat.bassScaleMinor : breakbeat.bassScale;
// Queue BPM change for next bar (don't change mid-beat!)
let pendingBpm = null;
const clamp = (v, lo, hi) => Math.max(lo, Math.min(hi, v));
const patternDensity = (arr) => {
let on = 0;
for (let i = 0; i < 16; i++) if (arr[i]) on++;
return on / 16;
};
const autoDnsFor = (voice) => {
const cfg = breakbeat.evolve || {};
const tgt = (cfg.target && typeof cfg.target[voice] === 'number') ? cfg.target[voice] : 0.3;
const pat = breakbeat.patterns[voice];
if (!pat) return 0;
const pd = patternDensity(pat);
const hits = (breakbeat.prevBarHits && typeof breakbeat.prevBarHits[voice] === 'number') ? breakbeat.prevBarHits[voice] : 0;
const hd = clamp(hits / 16, 0, 1);
const cur = 0.65 * pd + 0.35 * hd;
const diff = tgt - cur;
return clamp(Math.round(diff * 120), -30, 30);
};
const dnsBias = (voice) => {
const cfg = breakbeat.evolve || {};
const base = (typeof cfg.dns === 'number' && isFinite(cfg.dns)) ? cfg.dns : 0;
return clamp(base + autoDnsFor(voice), -30, 30);
};
const chaos = () => {
const cfg = breakbeat.evolve || {};
const c = (typeof cfg.chaos === 'number' && isFinite(cfg.chaos)) ? cfg.chaos : 3;
return clamp(c, 1, 10);
};
const lockRoot = () => {
const cfg = breakbeat.evolve || {};
return !!cfg.lockRoot;
};
for (let m = 0; m < intensity; m++) {
// Pick random mutation type
const c = chaos();
const mutationPool = (c <= 3)
? ['shift', 'addRm', 'swap', 'oct']
: ['shift', 'addRm', 'swap', 'oct', 'euclid', 'bpm'];
const mutation = mutationPool[Math.floor(Math.random() * mutationPool.length)];
// Pick random voice to mutate (pad/ghosts included)
// Ensure all percussion voices are in the pool so they can be "discovered" by evolution
const voices = voiceList || ['kick', 'snare', 'hihat', 'clap', 'rimshot', 'shaker', 'cowbell', 'tom', 'conga', 'bass', 'pad'];
const voice = voices[Math.floor(Math.random() * voices.length)];
const pattern = breakbeat.patterns[voice];
if (mutation === 'shift') {
// Rotate pattern left or right
const dir = Math.random() < 0.5 ? 1 : -1;
const temp = [...pattern];
for (let i = 0; i < 16; i++) {
pattern[i] = temp[(i - dir + 16) % 16];
}
} else if (mutation === 'addRm') {
// Add or remove a random hit
let step = Math.floor(Math.random() * 16);
if (voice === 'bass' && Math.random() < 0.7) {
const motif = [0, 6, 7, 10, 14, 15];
step = motif[Math.floor(Math.random() * motif.length)];
}
if (voice === 'pad' && Math.random() < 0.45) {
const motif = [0, 4, 7, 10, 12, 14];
step = motif[Math.floor(Math.random() * motif.length)];
}
if (lockRoot() && step === 0 && (voice === 'bass' || voice === 'pad')) {
continue;
}
const d = dnsBias(voice);
const addBias = (d + 50) / 100;
if (voice === 'bass') {
// For bass, add a scale note or remove
if (pattern[step]) {
if (Math.random() > addBias) pattern[step] = 0;
} else {
if (Math.random() < addBias) pattern[step] = scale[Math.floor(Math.random() * scale.length)];
}
} else if (voice === 'pad') {
// For pad, add a pad scale note or remove
const padScale = (breakbeat.harmony && breakbeat.harmony.mode === 'minor') ? breakbeat.padScaleMinor : breakbeat.padScale;
if (pattern[step]) {
if (Math.random() > addBias) pattern[step] = 0;
} else {
if (Math.random() < addBias) pattern[step] = padScale[Math.floor(Math.random() * padScale.length)];
}
} else {
if (pattern[step]) {
if (Math.random() > addBias) pattern[step] = 0;
} else {
if (Math.random() < addBias) pattern[step] = 1;
}
}
} else if (mutation === 'swap') {
// Swap two random steps
const a = Math.floor(Math.random() * 16);
const b = Math.floor(Math.random() * 16);
if (lockRoot() && (voice === 'bass' || voice === 'pad') && (a === 0 || b === 0)) {
continue;
}
const temp = pattern[a];
pattern[a] = pattern[b];
pattern[b] = temp;
} else if (mutation === 'oct' && voice === 'bass') {
// Shift bass notes up or down an octave
for (let i = 0; i < 16; i++) {
if (lockRoot() && i === 0) continue;
const c = chaos();
const changeChance = 0.08 + (c * 0.03);
if (pattern[i] && Math.random() < changeChance) {
const shift = Math.random() < 0.5 ? 12 : -12;
const newNote = pattern[i] + shift;
// Keep within reasonable range
if (newNote >= 24 && newNote <= 60) {
pattern[i] = newNote;
}
}
}
} else if (mutation === 'euclid' && voice === 'bass') {
// Euclidean bass pattern! Classic chill pulses: 3, 4, 5, or 7
const pulses = [3, 4, 5, 7][Math.floor(Math.random() * 4)];
const rotation = Math.floor(Math.random() * 4); // 0-3 rotation
breakbeat.patterns.bass = euclidBass(pulses, rotation);
} else if (mutation === 'bpm') {
// Queue BPM change (applied at bar boundary)
pendingBpm = breakbeat.bpm + (Math.random() - 0.5) * 4;
pendingBpm = Math.max(75, Math.min(88, pendingBpm));
}
}
// Apply pending BPM at end (will take effect next bar)
if (pendingBpm !== null) {
breakbeat.pendingBpm = pendingBpm;
}
// Occasional bigger changes on high intensity
if (intensity >= 3) {
// Maybe generate euclidean bass pattern
if (Math.random() < 0.5) {
const pulses = [3, 4, 5, 7][Math.floor(Math.random() * 4)];
const rotation = Math.floor(Math.random() * 4);
breakbeat.patterns.bass = euclidBass(pulses, rotation);
}
// Maybe queue BPM change (unless locked)
if (Math.random() < 0.4 && !musicSettings.bpmLocked) {
const bpmRange = musicSettings.bpmMax - musicSettings.bpmMin;
breakbeat.pendingBpm = musicSettings.bpmMin + Math.random() * bpmRange;
}
}
}
function scheduler() {
const ctx = initAudio();
let secondsPerBeat = 60.0 / breakbeat.bpm / 4; // 16th notes
while (breakbeat.nextNoteTime < ctx.currentTime + 0.1) {
scheduleNote(breakbeat.currentStep, breakbeat.nextNoteTime);
// Director tick (execute $1010 script events for this step)
if (breakbeat.director && breakbeat.director.running) {
breakbeat.director.tick(breakbeat.currentStep);
}
// Apply swing to off-beat 16ths (steps 1,3,5,7,9,11,13,15)
const swing = breakbeat.swing || 0;
const swingAmount = (breakbeat.currentStep % 2 === 1) ? secondsPerBeat * swing * 0.5 : 0;
breakbeat.nextNoteTime += secondsPerBeat + swingAmount;
const oldStep = breakbeat.currentStep;
breakbeat.currentStep = (breakbeat.currentStep + 1) % breakbeat.steps;
// Apply step drift (like pixelsynth - happens on step boundaries, not time intervals)
// This makes drift musically synchronized, not visually random
if (oldStep !== breakbeat.currentStep) {
applyDrift();
}
// At bar boundary (step 0)
if (breakbeat.currentStep === 0) {
breakbeat.prevBarHits = { ...breakbeat.barHits };
breakbeat.barHits = { bass: 0, pad: 0 };
breakbeat.prevBarStepHits = breakbeat.barStepHits;
breakbeat.barStepHits = { bass: Array(16).fill(0) };
breakbeat.prevBarOnCount = breakbeat.barOnCount || 0;
breakbeat.barOnCount = 0;
if (breakbeat.rallyBarsRemaining > 0) {
breakbeat.rallyBarsRemaining--;
}
// If it stays sparse for >1 bar, force a "rally" bar where most voices play.
// Threshold tuned for this engine (counts only voices that actually fired).
const NOODLY_THRESHOLD = musicSettings.rallyThreshold;
if (breakbeat.rallyBarsRemaining <= 0) {
if (breakbeat.prevBarOnCount < NOODLY_THRESHOLD) {
breakbeat.noodlyBars = (breakbeat.noodlyBars || 0) + 1;
} else {
breakbeat.noodlyBars = 0;
}
if (breakbeat.noodlyBars >= 2) {
breakbeat.rallyBarsRemaining = 1;
breakbeat.noodlyBars = 0;
logEvolution('RALLY mode triggered');
}
}
breakbeat.barCount++;
maybeAutoSnapshot();
// Skip evolution during kit preview mode
if (!breakbeat.previewMode) {
// ALWAYS advance - let users interact even while chapters are playing
rollBarInstincts(false);
rollMotif(false);
advanceChapterOnBar();
}
// DJ-style smooth BPM transition (ramp over N bars) - skip if BPM locked
const trans = breakbeat.bpmTransition;
if (trans && trans.active && trans.barsRemaining > 0 && !seqBpmLocked) {
// Interpolate BPM: ease-in-out for smooth DJ feel
const progress = 1 - (trans.barsRemaining / trans.barsTotal);
const eased = progress < 0.5
? 2 * progress * progress
: 1 - Math.pow(-2 * progress + 2, 2) / 2; // ease-in-out quad
breakbeat.bpm = trans.startBpm + (trans.targetBpm - trans.startBpm) * eased;
trans.barsRemaining--;
if (trans.barsRemaining <= 0) {
breakbeat.bpm = trans.targetBpm;
trans.active = false;
}
secondsPerBeat = 60.0 / breakbeat.bpm / 4;
} else if (breakbeat.pendingBpm && !seqBpmLocked) {
// Start a smooth transition instead of instant jump (skip if BPM locked)
const BPM_MIN = musicSettings.bpmMin, BPM_MAX = musicSettings.bpmMax;
const targetBpm = Math.max(BPM_MIN, Math.min(BPM_MAX, breakbeat.pendingBpm));
const diff = Math.abs(targetBpm - breakbeat.bpm);
// Only smooth-transition if difference is > 2 bpm
if (diff > 2) {
trans.active = true;
trans.startBpm = breakbeat.bpm;
trans.targetBpm = targetBpm;
trans.barsTotal = Math.max(2, Math.min(8, Math.round(diff / 2))); // 2-8 bars based on diff
trans.barsRemaining = trans.barsTotal;
logEvolution(`BPM transition: ${Math.round(trans.startBpm)}${Math.round(targetBpm)} (${trans.barsTotal}bars)`);
} else {
breakbeat.bpm = targetBpm;
secondsPerBeat = 60.0 / breakbeat.bpm / 4;
}
breakbeat.pendingBpm = null;
} else if (!trans.active && !seqBpmLocked) {
// Subtle "DJ drift": nudge BPM by ±0.3-0.8 every few bars so it feels alive
// User won't notice, but it prevents robotic feel (skip if BPM locked)
if (Math.random() < 0.25) {
const BPM_MIN = musicSettings.bpmMin, BPM_MAX = musicSettings.bpmMax;
const drift = (Math.random() - 0.5) * 1.2; // ±0.6 bpm max
breakbeat.bpm = Math.max(BPM_MIN, Math.min(BPM_MAX, breakbeat.bpm + drift));
secondsPerBeat = 60.0 / breakbeat.bpm / 4;
}
}
// Check recording boundaries (start/stop on the one)
checkRecordingBoundary();
// Controlled melodic evolution: probabilistic trigger with 2/4/8-bar cooldown
const evo = breakbeat.evolve || {};
if (typeof evo.cooldownBarsRemaining !== 'number' || !isFinite(evo.cooldownBarsRemaining)) {
evo.cooldownBarsRemaining = 0;
}
if (evo.cooldownBarsRemaining > 0) {
evo.cooldownBarsRemaining--;
} else {
// Low-chaos feel: don't evolve every time; when we do, hold off for 2/4/8 bars
if (Math.random() < 0.35) {
evolvePatterns(1, ['bass', 'pad']);
const intervals = [2, 4, 8];
const weights = [0.35, 0.45, 0.20];
const r = Math.random();
const pick = (r < weights[0]) ? intervals[0] : (r < weights[0] + weights[1]) ? intervals[1] : intervals[2];
evo.cooldownBarsRemaining = pick - 1;
}
}
}
}
breakbeat.timerID = setTimeout(scheduler, 25);
}
function rollMotif(force = false) {
// Generate short melodic fragments via accents/counter-rhythms.
// Holds for 1–2 bars; regenerated probabilistically at bar boundaries.
if (!breakbeat.motif) {
breakbeat.motif = { notes: Array(16).fill(0), voice: 'xsidLead', barsRemaining: 0, isCadence: false };
}
// If motif phrase is LOCKED (-1), don't change the current motif
if (seqMotifBars === -1 && !force) {
return; // Keep current motif, don't regenerate
}
const m = breakbeat.motif;
const wasActive = m.notes.some(n => n > 0);
if (!force) {
if (m.barsRemaining > 0) {
m.barsRemaining--;
return;
}
}
// Decide whether to generate a motif for the next 1–2 bars.
const motifChance = (musicSettings.motifChance || 55) / 100;
const want = force ? false : (Math.random() < motifChance);
const cadenceBar = (!force) && (breakbeat.barCount % 8 === 0 || breakbeat.barCount % 4 === 0);
if (!want) {
// Phrase punctuation: on cadence bars, allow a minimal tonic "tag" even if we otherwise skip.
if (cadenceBar && Math.random() < motifChance) {
const scale = (breakbeat.harmony && breakbeat.harmony.mode === 'minor') ? breakbeat.padScaleMinor : breakbeat.padScale;
const out = Array(16).fill(0);
out[15] = scale[0];
m.notes = out;
m.voice = 'xsidLead';
m.isCadence = true;
m.barsRemaining = 0;
} else {
m.notes = Array(16).fill(0);
m.barsRemaining = 0;
m.isCadence = false;
}
return;
}
const scale = (breakbeat.harmony && breakbeat.harmony.mode === 'minor') ? breakbeat.padScaleMinor : breakbeat.padScale;
// Rhythmic templates: sparse counter-rhythm patterns (16 steps)
const TEMPLATES = [
[0, 3, 6, 10, 14],
[2, 5, 7, 11, 13, 15],
[1, 4, 9, 12],
[0, 4, 7, 11, 15]
];
const tpl = TEMPLATES[Math.floor(Math.random() * TEMPLATES.length)];
const rot = Math.floor(Math.random() * 4); // 0..3 steps
// Pick an accent voice to carry the motif (7 voices for variety)
// Use forced voice if set, otherwise random
if (musicSettings.motifVoice) {
m.voice = musicSettings.motifVoice;
} else {
const r = Math.random();
if (r < 0.22) m.voice = 'xsidLead'; // 22% - classic SID lead
else if (r < 0.36) m.voice = 'chipLead'; // 14% - 8-bit PWM arp
else if (r < 0.50) m.voice = 'marsLead'; // 14% - Broken toy organ
else if (r < 0.62) m.voice = 'xpoly'; // 12% - poly texture
else if (r < 0.74) m.voice = 'xwave'; // 12% - Game Boy wavetable
else if (r < 0.84) m.voice = 'fmWhammy'; // 10% - FM pitch bend
else if (r < 0.92) m.voice = 'fmPad'; // 8% - ambient FM (quiet, filtered)
else m.voice = 'fmBrass'; // 8% - FM brass stab
}
// Stepwise melody: small moves within the scale (codified instinct)
let idx = Math.floor(Math.random() * scale.length);
const out = Array(16).fill(0);
for (let i = 0; i < tpl.length; i++) {
const step = (tpl[i] + rot) & 15;
// Favor repeating notes; otherwise step up/down by 1-2 scale degrees
const move = Math.random();
if (move < 0.55) {
// keep
} else if (move < 0.80) {
idx += (Math.random() < 0.5) ? 1 : -1;
} else {
idx += (Math.random() < 0.5) ? 2 : -2;
}
idx = Math.max(0, Math.min(scale.length - 1, idx));
out[step] = scale[idx];
}
// Bass call/response: if bass hit on step s last bar, bias motif hits at s+3 / s+5.
const callResponseChance = (musicSettings.callResponse || 65) / 100;
const prevBass = (breakbeat.prevBarStepHits && breakbeat.prevBarStepHits.bass) ? breakbeat.prevBarStepHits.bass : null;
if (prevBass && callResponseChance > 0) {
for (let s = 0; s < 16; s++) {
if (!prevBass[s]) continue;
const t3 = (s + 3) & 15;
const t5 = (s + 5) & 15;
if (!out[t3] && Math.random() < callResponseChance) {
const drift = (Math.random() < 0.6) ? 0 : (Math.random() < 0.5) ? 1 : -1;
idx = Math.max(0, Math.min(scale.length - 1, idx + drift));
out[t3] = scale[idx];
}
if (!out[t5] && Math.random() < callResponseChance * 0.7) {
const drift = (Math.random() < 0.65) ? 0 : (Math.random() < 0.5) ? 1 : -1;
idx = Math.max(0, Math.min(scale.length - 1, idx + drift));
out[t5] = scale[idx];
}
}
}
// Cadence punctuation: every 4/8 bars, thin density and force a tonic resolution.
if (cadenceBar) {
m.isCadence = true;
const thinProb = (breakbeat.barCount % 8 === 0) ? 0.55 : 0.40;
for (let s = 0; s < 16; s++) {
if (out[s] && Math.random() < thinProb) out[s] = 0;
}
let lastStep = -1;
for (let s = 15; s >= 0; s--) {
if (out[s]) { lastStep = s; break; }
}
if (lastStep < 0) lastStep = 15;
out[lastStep] = scale[0];
} else {
m.isCadence = false;
}
m.notes = out;
// Use user-configured motif bar length with some variation
// Supports: 0 (random S&H), 1, 2, 3, 4, 6, 8, 12, 16, 32, 64, -1 (lock)
let maxBars;
if (seqMotifBars === 0) {
// Random S&H mode: pick random phrase length, weighted toward shorter
const phraseOptions = [1, 2, 2, 3, 4, 4, 4, 6, 8, 8, 12, 16];
maxBars = phraseOptions[Math.floor(Math.random() * phraseOptions.length)];
} else {
maxBars = seqMotifBars > 0 ? seqMotifBars : (musicSettings.motifBars || 4);
}
let bars;
if (maxBars <= 1) {
bars = 1;
} else if (maxBars === 2) {
bars = (Math.random() < 0.65) ? 1 : 2;
} else if (maxBars === 3) {
bars = (Math.random() < 0.5) ? 2 : 3;
} else if (maxBars === 4) {
bars = (Math.random() < 0.4) ? 2 : (Math.random() < 0.7) ? 4 : 1;
} else if (maxBars === 6) {
bars = (Math.random() < 0.4) ? 3 : (Math.random() < 0.7) ? 6 : 2;
} else if (maxBars === 8) {
bars = (Math.random() < 0.3) ? 4 : (Math.random() < 0.7) ? 8 : 2;
} else if (maxBars <= 16) {
// 12-16 bars: longer phrases with some variation
bars = (Math.random() < 0.3) ? Math.floor(maxBars / 2) : (Math.random() < 0.8) ? maxBars : 4;
} else {
// 32-64 bars: very long phrases, mostly stay at max
bars = (Math.random() < 0.2) ? Math.floor(maxBars / 2) : maxBars;
}
m.barsRemaining = bars - 1;
// Log motif changes
const noteCount = out.filter(n => n > 0).length;
if (noteCount > 0) {
logEvolution(`MOTIF ${m.voice} (${noteCount} notes, ${bars}bar)${m.isCadence ? ' [cadence]' : ''}`);
}
}
function startBreakbeat() {
if (breakbeat.isPlaying) return;
initAudio();
// Randomize BPM within user's configured range (unless locked)
if (!seqBpmLocked) {
const bpmRange = musicSettings.bpmMax - musicSettings.bpmMin;
breakbeat.bpm = musicSettings.bpmMin + Math.random() * bpmRange;
}
// Randomize patterns slightly
randomizePatterns();
breakbeat.isPlaying = true;
breakbeat.currentStep = 0;
breakbeat.barCount = 0;
breakbeat.barInstinct.lockBarsRemaining = 0;
rollBarInstincts(true);
breakbeat.nextNoteTime = audioCtx.currentTime;
scheduler();
startTitleBeat();
// Show recording button
if (window.MediaRecorder) {
document.getElementById('recBtn').style.display = 'inline-block';
} else {
document.getElementById('recBtn').style.display = 'none';
}
}
function stopBreakbeat() {
breakbeat.isPlaying = false;
if (breakbeat.timerID) {
clearTimeout(breakbeat.timerID);
breakbeat.timerID = null;
}
}
window.listSeedKits = function () {
try {
randomizePatterns; // keep reference alive
} catch (e) { }
const kits = (breakbeat && breakbeat.availableKits) ? breakbeat.availableKits : [];
return kits.map(k => k.name);
};
window.setSeedKit = function (name) {
breakbeat.kitOverrideName = (name === null || name === undefined) ? null : String(name);
return breakbeat.kitOverrideName;
};
window.clearSeedKit = function () {
breakbeat.kitOverrideName = null;
return null;
};
window.setSeedKitAndReroll = function (name) {
window.setSeedKit(name);
randomizePatterns();
return breakbeat.kitOverrideName;
};
window.musicLogOn = true;
window.getSeedKitStatus = function () {
return {
kitOverrideName: breakbeat.kitOverrideName,
currentKitName: breakbeat.currentKitName || null,
bpm: breakbeat.bpm,
harmony: (breakbeat.harmony && breakbeat.harmony.mode) ? breakbeat.harmony.mode : null,
chorusOn: !!(breakbeat.barInstinct && breakbeat.barInstinct.chorusOn),
chorusBarsRemaining: (breakbeat.barInstinct && Number.isFinite(breakbeat.barInstinct.chorusLockBarsRemaining)) ? breakbeat.barInstinct.chorusLockBarsRemaining : 0,
rallyBarsRemaining: breakbeat.rallyBarsRemaining || 0,
motifCadence: !!(breakbeat.motif && breakbeat.motif.isCadence)
};
};
function randomizePatterns() {
// Apply user's scale setting
applyScaleToBreakbeat();
// Randomly choose lead synth type: FM stabs or Chip PWM arps
breakbeat.leadType = Math.random() < 0.5 ? 'chip' : 'fm';
// Occasionally shift "mix style" (pack-style drum/bass character)
if (!breakbeat.mixStyleName) breakbeat.mixStyleName = 'DEFAULT';
if (Math.random() < 0.18) {
const pick = MIX_STYLES[Math.floor(Math.random() * MIX_STYLES.length)];
setMixStyleByName(pick.name);
}
// Optional "KITS" (1010seq-inspired) seed patterns.
// These only seed the base rhythms/melodies; all your existing ghost-note,
// probability, evolve, and hat behavior still applies.
//
// IMPORTANT: Use getAvailableKits() to avoid duplicate kit definitions!
// The authoritative kit list is in getAvailableKits()
const kits = getAvailableKits();
breakbeat.availableKits = kits;
// 51% chance to seed from a kit (still goes through your smoothing/ghosting)
const overrideName = (breakbeat.kitOverrideName && String(breakbeat.kitOverrideName).trim())
? String(breakbeat.kitOverrideName).trim()
: null;
const overrideKit = overrideName
? (kits.find(k => k && k.name === overrideName) || null)
: null;
// Filter kits by enabledKits pool (from Advanced settings)
const availableKits = (enabledKits && enabledKits.length > 0)
? kits.filter(k => enabledKits.includes(k.name))
: kits;
const useKit = !!overrideKit || (Math.random() < musicSettings.kitChance);
const kit = overrideKit ? overrideKit : (useKit && availableKits.length > 0 ? availableKits[Math.floor(Math.random() * availableKits.length)] : null);
breakbeat.currentKitName = kit ? kit.name : null;
if (kit) {
logEvolution(`KIT: ${kit.name}`);
}
// Kit BPM influences our tempo within the user's configured range (not an override)
// Faster kits bias toward high end, slower kits bias toward low end
const BPM_MIN = musicSettings.bpmMin, BPM_MAX = musicSettings.bpmMax;
let targetBpm;
if (kit && kit.bpm) {
// Map kit BPM (typically 70-130) into our range with some randomness
// Faster kit = bias high, slower kit = bias low, but always stay in range
const kitFactor = Math.max(0, Math.min(1, (kit.bpm - 70) / 60)); // 0-1 based on kit speed
const biasedMid = BPM_MIN + kitFactor * (BPM_MAX - BPM_MIN);
targetBpm = biasedMid + (Math.random() - 0.5) * 6; // ±3 around the biased point
targetBpm = Math.max(BPM_MIN, Math.min(BPM_MAX, targetBpm));
} else {
targetBpm = BPM_MIN + Math.random() * (BPM_MAX - BPM_MIN);
}
if (!seqBpmLocked) {
// DJ-style BPM drift - never jump, always glide
// Set target, actual BPM will drift toward it each bar
breakbeat.targetBpm = targetBpm;
if (!breakbeat.isPlaying) {
// If stopped, can set directly
breakbeat.bpm = targetBpm;
}
// If playing, driftBpmTowardTarget() handles the gradual change
}
if (window.musicLogOn) {
const tag = kit ? `KIT: ${kit.name}` : 'KIT: (none)';
const mode = (breakbeat.harmony && breakbeat.harmony.mode) ? breakbeat.harmony.mode : 'major';
const chorus = !!(breakbeat.barInstinct && breakbeat.barInstinct.chorusOn);
const rally = (breakbeat.rallyBarsRemaining || 0) > 0;
console.log(`[MUSIC] ${tag} | mode=${mode} | chorus=${chorus} | rally=${rally} | bpm=${Math.round(targetBpm)}`);
}
const quantizeNoteToScale = (note, scale) => {
if (!note || !Array.isArray(scale) || scale.length === 0) return note;
let best = scale[0];
let bestD = Infinity;
for (let i = 0; i < scale.length; i++) {
const base = scale[i];
for (const sh of [-12, 0, 12]) {
const cand = base + sh;
const d = Math.abs(cand - note);
if (d < bestD) {
bestD = d;
best = cand;
}
}
}
return best;
};
// Kick patterns from 1010seq genres
const kickPatterns = [
[1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0], // Four on floor (House)
[1, 0, 0, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0, 0, 0], // Breakbeat
[1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0], // DnB
[1, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0], // Minimal
[1, 0, 0, 1, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0, 1, 0], // UK Garage
[1, 0, 0, 0, 0, 0, 1, 0, 1, 0, 0, 0, 0, 0, 1, 0], // Hip-Hop
[1, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 1, 0, 0, 0], // Electro
[1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0], // Dubstep
[1, 0, 0, 0, 0, 0, 0, 0, 1, 0, 1, 0, 0, 0, 0, 0],
[1, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 1, 0, 0, 0],
[1, 0, 1, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0],
];
// Snare patterns from 1010seq genres
const snarePatterns = [
[0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0], // Classic backbeat
[0, 0, 0, 0, 1, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0], // Breakbeat ghost
[0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0], // Deep House (snare on 3)
[0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 1], // Tech House
[0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 1, 0, 1, 0], // Grime
[0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 1, 0], // Dubstep half-time
[0, 0, 0, 0, 1, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 1], // Sonic style
[0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 1, 0, 1, 0, 0, 1],
[0, 0, 0, 0, 1, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0],
];
// Pick random kick and snare
const baseKick = kickPatterns[Math.floor(Math.random() * kickPatterns.length)];
const baseSnare = snarePatterns[Math.floor(Math.random() * snarePatterns.length)];
// Grace period for kits: Let them play pure for first 2 bars
const gracePeriod = kit && (breakbeat.barCount || 0) < 2;
// Add some random ghost notes to main voices (skip during grace period)
breakbeat.patterns.kick = (kit ? kit.kick : baseKick).map((v, i) => {
if (v) return 1;
return gracePeriod ? 0 : (Math.random() < 0.08 ? 1 : 0);
});
breakbeat.patterns.snare = (kit ? kit.snare : baseSnare).map((v, i) => {
if (v) return 1;
return gracePeriod ? 0 : (Math.random() < 0.05 ? 1 : 0);
});
// Map kit.noise to hihat (was always random before)
breakbeat.patterns.hihat = kit && kit.noise ?
kit.noise.map(v => v ? 1 : 0) :
new Array(16).fill(0).map((_, i) => {
return Math.random() < 0.85 ? 1 : 0;
});
breakbeat.patterns.clap = [0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0];
// Ghost voices - sparse random accents (skip during grace period)
breakbeat.patterns.rimshot = new Array(16).fill(0).map(() => gracePeriod ? 0 : (Math.random() < 0.08 ? 1 : 0));
breakbeat.patterns.shaker = new Array(16).fill(0).map((_, i) => {
// Shaker on offbeats sometimes (not during grace period)
return (!gracePeriod && i % 2 === 1 && Math.random() < 0.3) ? 1 : 0;
});
breakbeat.patterns.cowbell = new Array(16).fill(0).map((_, i) => {
// Cowbell very sparse - maybe 1-2 hits per bar (not during grace period)
return (!gracePeriod && Math.random() < 0.06) ? 1 : 0;
});
breakbeat.patterns.tom = new Array(16).fill(0).map((_, i) => {
// Toms as fills - more likely near end of bar (not during grace period)
return (!gracePeriod && i >= 12 && Math.random() < 0.15) ? 1 : 0;
});
breakbeat.patterns.conga = new Array(16).fill(0).map(() => (!gracePeriod && Math.random() < 0.1) ? 1 : 0);
// SID Bass - classic game-inspired patterns from 1010seq
const scale = breakbeat.bassScale;
const root = scale[0]; // Root note
const fifth = scale[4]; // Fifth
const third = scale[2]; // Third
const fourth = scale[3]; // Fourth
// Classic bassline patterns inspired by arcade games
const bassPatterns = [
// Donkey Kong style - bouncy
[root, 0, 0, root, 0, 0, root, 0, fifth, 0, 0, fifth, 0, 0, fifth, 0],
// Mario style - rhythmic
[root, 0, 0, root, 0, 0, root, 0, fourth, 0, 0, fourth, 0, 0, fourth, 0],
// Sonic style - driving 8ths
[root, root, fourth, fourth, third, third, fourth, fourth, root, root, fourth, fourth, third, third, fourth, fourth],
// OutRun style - pumping
[root, root, root, root, fifth, fifth, fifth, fifth, root, root, root, root, fifth, fifth, fifth, fifth],
// Mega Man style - alternating
[root, root, third, third, fifth, fifth, third, third, root, root, third, third, fifth, fifth, third, third],
// Castlevania style - gallop
[fifth, fifth, fifth, fifth, root, root, root, root, fifth, fifth, fifth, fifth, root, root, root, root],
// Galaga style - two-note pulse
[root, root, root, root, third, third, third, third, root, root, root, root, third, third, third, third],
// Street Fighter style - syncopated
[root, 0, root, 0, 0, 0, root, 0, fifth, 0, fifth, 0, 0, 0, fifth, 0],
// House style - sparse
[root, 0, 0, 0, 0, 0, 0, 0, root, 0, 0, 0, 0, 0, 0, 0],
// Acid style - chromatic walk
[root, 0, 0, 0, third, 0, root, 0, fifth, 0, 0, 0, third, 0, root, 0],
// Zelda style - melodic
[root, 0, 0, root, 0, 0, root, 0, fifth, 0, 0, fifth, 0, 0, fifth, 0],
// Techno style - offbeat
[0, 0, root, 0, 0, 0, root, 0, 0, 0, fifth, 0, 0, 0, root, 0],
// Minimal - just downbeats
[root, 0, 0, 0, 0, 0, 0, 0, fifth, 0, 0, 0, 0, 0, 0, 0],
// Dub style - one drop
[root, 0, 0, 0, 0, 0, 0, 0, 0, 0, root, 0, 0, 0, 0, 0],
[root, 0, 0, 0, 0, 0, fifth, 0, root, 0, 0, 0, 0, 0, fourth, 0],
[root, 0, root, 0, 0, 0, root, 0, 0, 0, root, 0, 0, root, 0, 0],
];
// Pick a random bassline or use kit bass
breakbeat.patterns.bass = (kit ? kit.bass : bassPatterns[Math.floor(Math.random() * bassPatterns.length)]).slice();
// Don't quantize kit bass - it's already intentionally chosen!
// Only quantize random bass patterns
if (!kit) {
const bassScale = (breakbeat.harmony && breakbeat.harmony.mode === 'minor') ? breakbeat.bassScaleMinor : breakbeat.bassScale;
breakbeat.patterns.bass = breakbeat.patterns.bass.map(v => v ? quantizeNoteToScale(v, bassScale) : 0);
}
// Add occasional random notes from scale (skip during grace period)
breakbeat.patterns.bass = breakbeat.patterns.bass.map((v, i) => {
if (v) return v;
// Small chance to add a note from scale (not during grace period)
if (!gracePeriod && Math.random() < 0.08) {
return scale[Math.floor(Math.random() * scale.length)];
}
return 0;
});
// Bass ratchets - electro style rapid-fire on some notes
// 30% chance of ratchet mode this generation
const doRatchets = Math.random() < 0.30;
breakbeat.patterns.bassRatch = breakbeat.patterns.bass.map((note, i) => {
if (!note) return 1;
if (!doRatchets) return 1;
// 25% chance per note to get ratchets (2-6 hits)
if (Math.random() < 0.25) {
return 2 + Math.floor(Math.random() * 5); // 2-6 ratchets
}
return 1;
});
// FM Lead - map kit.lead to lead voice (not pad!)
// Pad is generated separately below
const padScale = breakbeat.padScale;
const p0 = padScale[0], p1 = padScale[1], p2 = padScale[2], p3 = padScale[3], p4 = padScale[4];
// Lead patterns from arcade games (used for pad voice)
const leadPatterns = [
// House style - chord stabs
[p0, 0, 0, p0, 0, 0, p2, 0, 0, 0, p1, 0, 0, 0, 0, 0],
// Detroit style - arpeggiated
[p0, 0, 0, p1, 0, 0, p2, 0, 0, 0, p3, 0, 0, 0, 0, 0],
// Prog style - sparse melodic
[p0, 0, 0, 0, 0, 0, 0, 0, p1, 0, 0, 0, 0, 0, p2, 0],
// Minimal - just root
[p0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
// Garage style - choppy
[p2, 0, 0, p1, 0, 0, p0, 0, 0, 0, p2, 0, 0, p1, 0, 0],
// Electro style - arp
[p0, 0, 0, 0, p1, 0, 0, 0, p2, 0, 0, 0, p1, 0, 0, 0],
[p0, 0, 0, 0, p2, 0, 0, 0, p3, 0, 0, 0, p2, 0, 0, 0],
[p0, 0, 0, p0, 0, 0, p0, 0, p0, 0, 0, p0, 0, 0, p0, 0],
// Empty - bass only
[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
];
// Map kit.lead to lead voice (the melodic lead)
if (kit && kit.lead) {
breakbeat.patterns.lead = kit.lead.slice();
// Don't quantize kit lead - it's intentionally chosen!
} else {
// Generate random lead if no kit
breakbeat.patterns.lead = leadPatterns[Math.floor(Math.random() * leadPatterns.length)].slice();
}
// Pad - use kit.pad if available, otherwise generate from leadPatterns
if (kit && kit.pad) {
breakbeat.patterns.pad = kit.pad.slice();
// Don't quantize kit pad - it's intentionally chosen!
// Don't add random notes to kit pad patterns - preserve the exact pattern!
} else {
breakbeat.patterns.pad = leadPatterns[Math.floor(Math.random() * leadPatterns.length)].slice();
// Add occasional random notes (skip during grace period) - only for non-kit patterns
breakbeat.patterns.pad = breakbeat.patterns.pad.map((v, i) => {
if (v) return v;
// Small chance to add a note on offbeats (not during grace period)
if (!gracePeriod && i % 4 !== 0 && Math.random() < 0.05) {
return padScale[Math.floor(Math.random() * padScale.length)];
}
return 0;
});
}
}
let player1 = {
x: 150,
y: 200,
size: 20,
color: colors.magenta,
vx: 0,
vy: 0,
trail: [] // Position history for trail effect
};
let player2 = {
x: 250,
y: 200,
size: 20,
color: colors.cyan,
vx: 0,
vy: 0,
trail: [] // Position history for trail effect
};
let keys = {};
function updateTrail(player) {
// Add current position to trail
player.trail.unshift({ x: player.x, y: player.y });
// Keep only last 5 positions
if (player.trail.length > 5) {
player.trail.pop();
}
}
function drawTrail(player) {
if (!player.trail || player.trail.length < 2) return;
ctx.save();
ctx.globalCompositeOperation = 'lighter'; // Additive blend - fixes alpha stacking
for (let i = 1; i < player.trail.length; i++) {
const pos = player.trail[i];
const alpha = (1 - i / player.trail.length) * 0.4;
const size = player.size * (1 - i / player.trail.length * 0.4);
ctx.globalAlpha = alpha;
ctx.fillStyle = player.color;
ctx.fillRect(pos.x - size / 2, pos.y - size / 2, size, size);
}
ctx.restore();
}
function drawPixelCharacter(x, y, size, color, type) {
ctx.fillStyle = color;
if (type === 1) {
// Magenta Character 1 (with bow)
ctx.fillRect(x - size / 2, y - size / 2, size, size);
ctx.fillStyle = colors.white;
ctx.fillRect(x - size / 4, y - size / 4, 3, 3);
ctx.fillRect(x + size / 4 - 3, y - size / 4, 3, 3);
ctx.fillStyle = colors.red;
ctx.fillRect(x - size / 2 - 5, y - size / 2 - 5, 10, 5);
} else {
// Cyan Character 2 (with newsboy cap)
ctx.fillRect(x - size / 2, y - size / 2, size, size);
ctx.fillStyle = colors.white;
ctx.fillRect(x - size / 4, y - size / 4, 3, 3);
ctx.fillRect(x + size / 4 - 3, y - size / 4, 3, 3);
// Newsboy cap - tilted back with swag
ctx.fillStyle = colors.yellow;
// Cap crown (tilted back, angled up)
ctx.fillRect(x - size / 2 + 4, y - size / 2 - 6, size - 8, 3);
ctx.fillRect(x - size / 2 + 2, y - size / 2 - 5, size - 4, 3);
ctx.fillRect(x - size / 2, y - size / 2 - 3, size, 3); // Extended down to meet brim
// Small front brim (angled down)
ctx.fillRect(x - size / 2 - 4, y - size / 2, 7, 2);
}
}
function drawFlower(x, y, size) {
// Flower petals (red)
ctx.fillStyle = colors.red;
ctx.fillRect(x - size / 3, y - size / 2, size / 3, size / 3);
ctx.fillRect(x, y - size / 2, size / 3, size / 3);
ctx.fillRect(x - size / 2, y - size / 3, size / 3, size / 3);
ctx.fillRect(x + size / 6, y - size / 3, size / 3, size / 3);
// Center (yellow)
ctx.fillStyle = colors.yellow;
ctx.fillRect(x - size / 6, y - size / 3, size / 3, size / 3);
// Stem (green)
ctx.fillStyle = colors.green;
ctx.fillRect(x - 2, y, 4, size / 2);
}
// Poison Ivy (green with purple spots)
function drawPoisonIvy(x, y) {
ctx.fillStyle = colors.green;
ctx.fillRect(x - 4, y - 4, 8, 8);
ctx.fillStyle = colors.magenta;
ctx.fillRect(x - 2, y - 2, 2, 2);
ctx.fillRect(x + 1, y + 1, 2, 2);
}
// Coffee Cup (white cup, black coffee)
function drawCoffee(x, y) {
ctx.fillStyle = colors.white;
ctx.fillRect(x - 3, y - 2, 6, 6);
ctx.fillStyle = colors.black;
ctx.fillRect(x - 2, y - 1, 4, 3);
ctx.fillStyle = colors.white;
ctx.fillRect(x + 3, y, 2, 3); // handle
}
// Star (yellow sparkle)
function drawStar(x, y) {
ctx.fillStyle = colors.yellow;
ctx.fillRect(x, y - 4, 2, 9);
ctx.fillRect(x - 4, y, 9, 2);
ctx.fillRect(x - 2, y - 2, 5, 5);
}
// Bushes (green)
function drawObstacle(x, y, size) {
ctx.fillStyle = colors.green;
for (let i = 0; i < size; i += 4) {
for (let j = 0; j < size; j += 4) {
if ((i + j) % 8 === 0) {
ctx.fillRect(x + i, y + j, 3, 3);
}
}
}
}
function createParticle(x, y, color) {
game.particles.push({
x: x,
y: y,
vx: (Math.random() - 0.5) * 4,
vy: (Math.random() - 0.5) * 4,
life: 20,
color: color
});
}
function updateParticles() {
game.particles = game.particles.filter(p => {
p.x += p.vx;
p.y += p.vy;
p.life--;
p.vy += 0.2;
return p.life > 0;
});
}
function drawParticles() {
game.particles.forEach((p, i) => {
p.x += p.vx;
p.y += p.vy;
p.life--;
ctx.globalAlpha = p.life / 30;
ctx.fillStyle = p.color;
ctx.fillRect(p.x, p.y, 4, 4);
ctx.globalAlpha = 1;
if (p.life <= 0) game.particles.splice(i, 1);
});
}
function drawHeart(x, y, size, golden = false) {
if (golden) {
// Golden flower with glow - same structure as normal but yellow petals
ctx.shadowColor = '#FF0';
ctx.shadowBlur = 8;
// Petals (yellow instead of red)
ctx.fillStyle = colors.yellow;
ctx.fillRect(x - size / 3, y - size / 2, size / 3, size / 3);
ctx.fillRect(x, y - size / 2, size / 3, size / 3);
ctx.fillRect(x - size / 2, y - size / 3, size / 3, size / 3);
ctx.fillRect(x + size / 6, y - size / 3, size / 3, size / 3);
// Center (red - reverse of red flower with yellow center)
ctx.fillStyle = colors.red;
ctx.fillRect(x - size / 6, y - size / 3, size / 3, size / 3);
ctx.shadowBlur = 0;
// Stem (green)
ctx.fillStyle = colors.green;
ctx.fillRect(x - 2, y, 4, size / 2);
} else {
// Original pixel flower
drawFlower(x, y, size);
}
}
// Points popup for bonus feedback
let pointsPopups = [];
function showPointsPopup(x, y, points, golden, combo) {
let text = `+${points}`;
if (combo > 1) text += ` x${combo}`;
if (golden) text = '*' + text;
pointsPopups.push({ x, y, text, life: 45, golden });
}
function drawPointsPopups() {
pointsPopups = pointsPopups.filter(p => {
p.y -= 1.5;
p.life--;
ctx.globalAlpha = p.life / 45;
ctx.font = 'bold 16px DotGothic16';
ctx.textAlign = 'center';
ctx.fillStyle = p.golden ? '#FF0' : '#FFF';
ctx.strokeStyle = '#000';
ctx.lineWidth = 3;
ctx.strokeText(p.text, p.x, p.y);
ctx.fillText(p.text, p.x, p.y);
ctx.globalAlpha = 1;
return p.life > 0;
});
}
function drawComboMeter() {
if (game.combo > 1 && game.comboTimer > 0) {
ctx.font = 'bold 20px DotGothic16';
ctx.textAlign = 'center';
ctx.fillStyle = game.combo >= 4 ? '#FF0' : game.combo >= 2 ? '#0FF' : '#FFF';
ctx.strokeStyle = '#000';
ctx.lineWidth = 3;
const text = `${game.combo}x COMBO!`;
ctx.strokeText(text, canvas.width / 2, 30);
ctx.fillText(text, canvas.width / 2, 30);
}
}
function spawnHeart() {
if (game.hearts.length < 3) {
// 10% chance for golden flower (worth 2x)
const isGolden = Math.random() < 0.1;
game.hearts.push({
x: Math.random() * (canvas.width - 40) + 20,
y: Math.random() * (canvas.height - 40) + 20,
size: isGolden ? 18 : 15,
collected: false,
golden: isGolden
});
}
}
function spawnObstacle() {
// Spawn rate increases with level - first bush appears quickly!
// Level 1: 2% chance per frame = ~2-3 seconds for first bush
const spawnChance = game.level === 1 ? 0.02 : 0.015 + (game.level - 1) * 0.003;
const maxObstacles = game.level === 1 ? 1 : Math.min(5, 2 + Math.floor(game.level / 3));
// Start spawning once player has moved (keys or drag) - teaches dodge early!
if (game.obstacles.length < maxObstacles && Math.random() < spawnChance && game.movementUnlocked) {
// Base speed with VARIATION per obstacle
const baseSpeed = 1.0 + (game.level - 1) * 0.15;
const speedVariation = 0.5 + Math.random() * 1.0; // 0.5 to 1.5x multiplier
const rawSpeed = baseSpeed * speedVariation;
// Count fast obstacles (speed > 2.5) and limit to 3
const fastCount = game.obstacles.filter(o => o.speed > 2.5).length;
const cappedSpeed = fastCount >= 3 ? Math.min(2.5, rawSpeed) : Math.min(3.5, rawSpeed);
game.obstacles.push({
x: Math.random() * (canvas.width - 60) + 30,
y: -30,
size: 30,
speed: cappedSpeed
});
}
}
function checkCollision(obj1, obj2, threshold) {
const dx = obj1.x - obj2.x;
const dy = obj1.y - obj2.y;
return Math.sqrt(dx * dx + dy * dy) < threshold;
}
function updateLoveMeter() {
// Grace period: don't drain (or warn) right after starting a run
if (Date.now() < game.graceUntil) {
game.drainFlash = 0;
return;
}
const distance = Math.sqrt(
Math.pow(player1.x - player2.x, 2) +
Math.pow(player1.y - player2.y, 2)
);
// Dynamic line length: base + level bonus + temporary flower bonus
const levelBonus = (game.level - 1) * 10; // +10 per level
const maxLineLength = BASE_LINE_LENGTH + levelBonus + game.lineBonus;
// Decay line bonus over time
if (game.lineBonusTimer > 0) {
game.lineBonusTimer--;
} else if (game.lineBonus > 0) {
game.lineBonus = Math.max(0, game.lineBonus - 0.5); // Slow decay
}
// Thresholds scale with line length
const closeThreshold = maxLineLength * 0.5;
const farThreshold = maxLineLength;
const dangerThreshold = maxLineLength * 1.5;
if (distance < closeThreshold) {
game.loveMeter = Math.min(3, game.loveMeter + 0.01);
game.drainFlash = 0;
// Micro consonant interval cue when together (throttled)
if (!game.lastCloseChime) game.lastCloseChime = 0;
const now = Date.now();
if (now - game.lastCloseChime > 900 && Math.random() < 0.3) {
game.lastCloseChime = now;
playCloseIntervalChime();
}
} else if (distance > farThreshold) {
game.loveMeter = Math.max(0, game.loveMeter - 0.02);
game.drainFlash = Math.min(1, game.drainFlash + 0.1);
// Screen shake when draining fast
if (distance > dangerThreshold && Math.random() < 0.1) {
game.shakeTimer = 3;
playLoveWarningSound();
}
} else {
game.drainFlash = Math.max(0, game.drainFlash - 0.05);
}
if (game.loveMeter <= 0 && !game.gameOver) {
game.gameOver = true;
stopBreakbeat();
playGameOverSound();
document.getElementById('message').textContent = "💔 Game Over! 💔";
updateButtonState();
// Show a pause-style overlay over the frozen frame
const pauseOverlay = document.getElementById('pauseOverlay');
const pauseText = document.getElementById('pauseText');
if (pauseText) pauseText.textContent = 'ONE MORE CHANCE?';
if (pauseOverlay) pauseOverlay.style.display = 'flex';
// Show name entry after short delay
setTimeout(() => showNameEntry(), 500);
}
}
function updatePlayers() {
if (!game.movementUnlocked) {
// Absolutely no movement until the first intentional move input after Start
player1.vx = 0;
player1.vy = 0;
player2.vx = 0;
player2.vy = 0;
return;
}
const accel = 0.6;
const friction = 0.92;
const maxSpeed = 4.5;
// Player 1 controls (Magenta - WASD) - smooth acceleration
if (keys['a'] || keys['A']) player1.vx -= accel;
if (keys['d'] || keys['D']) player1.vx += accel;
if (keys['w'] || keys['W']) player1.vy -= accel;
if (keys['s'] || keys['S']) player1.vy += accel;
player1.vx *= friction;
player1.vy *= friction;
player1.vx = Math.max(-maxSpeed, Math.min(maxSpeed, player1.vx));
player1.vy = Math.max(-maxSpeed, Math.min(maxSpeed, player1.vy));
// Player 2 controls (Cyan - Arrow keys) - smooth acceleration
if (keys['ArrowLeft']) player2.vx -= accel;
if (keys['ArrowRight']) player2.vx += accel;
if (keys['ArrowUp']) player2.vy -= accel;
if (keys['ArrowDown']) player2.vy += accel;
player2.vx *= friction;
player2.vy *= friction;
player2.vx = Math.max(-maxSpeed, Math.min(maxSpeed, player2.vx));
player2.vy = Math.max(-maxSpeed, Math.min(maxSpeed, player2.vy));
// Update positions
player1.x = Math.max(10, Math.min(canvas.width - 10, player1.x + player1.vx));
player1.y = Math.max(10, Math.min(canvas.height - 10, player1.y + player1.vy));
player2.x = Math.max(10, Math.min(canvas.width - 10, player2.x + player2.vx));
player2.y = Math.max(10, Math.min(canvas.height - 10, player2.y + player2.vy));
// Update trails
updateTrail(player1);
updateTrail(player2);
// Create trail particles when moving fast
if (Math.abs(player1.vx) > 2 || Math.abs(player1.vy) > 2) {
createParticle(player1.x, player1.y, colors.magenta);
}
if (Math.abs(player2.vx) > 2 || Math.abs(player2.vy) > 2) {
createParticle(player2.x, player2.y, colors.cyan);
}
}
function gameLoop() {
if (game.paused || game.gameOver) return;
// Handle screen shake
if (game.shakeTimer > 0) {
canvas.classList.add('shake');
game.shakeTimer--;
} else {
canvas.classList.remove('shake');
}
// Clear canvas
ctx.fillStyle = colors.black;
ctx.fillRect(0, 0, canvas.width, canvas.height);
// Draw background pattern
ctx.fillStyle = colors.blue;
for (let i = 0; i < canvas.width; i += 40) {
for (let j = 0; j < canvas.height; j += 40) {
ctx.fillRect(i, j, 2, 2);
}
}
updatePlayers();
updateLoveMeter();
updateParticles();
// Spawn after grace period
if (Date.now() >= game.graceUntil) {
spawnHeart();
spawnObstacle();
}
// Update and draw hearts
game.hearts = game.hearts.filter(heart => {
if (!heart.collected) {
drawHeart(heart.x, heart.y, heart.size, heart.golden);
if (checkCollision(player1, heart, 20) || checkCollision(player2, heart, 20)) {
heart.collected = true;
// Combo system - collect within 2 seconds for multiplier
const now = Date.now();
if (now - game.lastCollectTime < 2000) {
game.combo = Math.min(game.combo + 1, 5);
game.comboTimer = 60;
} else {
game.combo = 1;
}
game.lastCollectTime = now;
// Calculate points: base * golden bonus * combo
const basePoints = 10;
const goldenMultiplier = heart.golden ? 2 : 1;
const comboMultiplier = game.combo;
const points = basePoints * goldenMultiplier * comboMultiplier;
game.score += points;
game.levelScore += points;
game.loveMeter = Math.min(3, game.loveMeter + 0.5);
// Line bonus: collecting flowers extends the love line temporarily
const lineExtension = heart.golden ? 30 : 15; // Golden gives more
game.lineBonus = Math.min(100, game.lineBonus + lineExtension); // Cap at +100
game.lineBonusTimer = 180; // ~3 seconds at 60fps before decay starts
playCollectSound();
// Track flowers collected for saved-run garden regeneration
game.gardenFlowerCount = (game.gardenFlowerCount || 0) + 1;
// Also show progress live in the background garden
addGardenFlower(heart.golden);
// Create celebration particles
for (let i = 0; i < 10; i++) {
createParticle(heart.x, heart.y, heart.golden ? colors.yellow : colors.red);
createParticle(heart.x, heart.y, colors.yellow);
}
// Show points popup for bonus
if (goldenMultiplier > 1 || comboMultiplier > 1) {
showPointsPopup(heart.x, heart.y, points, heart.golden, comboMultiplier);
}
return false;
}
}
return !heart.collected;
});
// Decay combo timer
if (game.comboTimer > 0) game.comboTimer--;
// Update and draw obstacles (use individual spawn speeds)
game.obstacles = game.obstacles.filter(obstacle => {
obstacle.y += obstacle.speed;
drawObstacle(obstacle.x, obstacle.y, obstacle.size);
if (checkCollision(player1, obstacle, 25) || checkCollision(player2, obstacle, 25)) {
game.loveMeter = Math.max(0, game.loveMeter - 1);
game.shakeTimer = 5;
playHurtSound();
document.getElementById('message').textContent = "😢 Ouch! Avoid the bushes!";
setTimeout(() => document.getElementById('message').textContent = "", 2000);
return false;
}
return obstacle.y < canvas.height + 30;
});
// Draw connection line - color changes based on distance
const distance = Math.sqrt(
Math.pow(player1.x - player2.x, 2) +
Math.pow(player1.y - player2.y, 2)
);
// Dynamic line length for visuals
const levelBonus = (game.level - 1) * 10;
const maxLineLength = BASE_LINE_LENGTH + levelBonus + game.lineBonus;
const closeThreshold = maxLineLength * 0.5;
const midThreshold = maxLineLength * 0.8;
{
// Love line should always be visible.
// Base state: bright light-gray. As it gets tight, pulse toward pink-red.
const danger = Math.min(1, Math.max(0, (distance - midThreshold) / (maxLineLength - midThreshold)));
const pulseSpeed = 240 - danger * 160; // faster when tight
const pulse = (Math.sin(Date.now() / pulseSpeed) + 1) / 2; // 0-1
// Spend more time in pink when danger is high.
const pinkBias = 0.15 + danger * 0.65; // 0.15..0.80
const pinkPhase = pulse < pinkBias;
const lineColor = pinkPhase ? '#F88' : '#BBB';
const lineWidth = 2.5 + danger * 0.5;
// Draw line from edges of players, not centers (so it's truly behind them)
const angle = Math.atan2(player2.y - player1.y, player2.x - player1.x);
const offset = player1.size / 2 + 2; // Start/end just outside player bounds
const x1 = player1.x + Math.cos(angle) * offset;
const y1 = player1.y + Math.sin(angle) * offset;
const x2 = player2.x - Math.cos(angle) * offset;
const y2 = player2.y - Math.sin(angle) * offset;
ctx.setLineDash([5, 5]);
ctx.globalAlpha = 0.65;
ctx.strokeStyle = '#000';
ctx.lineWidth = lineWidth + 2;
ctx.beginPath();
ctx.moveTo(x1, y1);
ctx.lineTo(x2, y2);
ctx.stroke();
ctx.globalAlpha = 1;
ctx.strokeStyle = lineColor;
ctx.lineWidth = lineWidth;
ctx.beginPath();
ctx.moveTo(x1, y1);
ctx.lineTo(x2, y2);
ctx.stroke();
ctx.setLineDash([]);
ctx.globalAlpha = 1;
}
// Draw players (after line so they appear in front)
drawTrail(player1);
drawTrail(player2);
drawPixelCharacter(player1.x, player1.y, player1.size, player1.color, 1);
drawPixelCharacter(player2.x, player2.y, player2.size, player2.color, 2);
// Draw particles and popups
drawParticles();
drawPointsPopups();
drawComboMeter();
// Check level up - when levelScore reaches 100
if (game.levelScore >= 100) {
game.level++;
game.levelScore = 0; // Reset for next level
game.graceUntil = Date.now() + 2500; // Brief grace after level up
playWinSound();
document.getElementById('message').textContent = `🌟 Level ${game.level}! 🌟`;
setTimeout(() => {
if (!game.gameOver) document.getElementById('message').textContent = '';
}, 2000);
}
// Update UI
const hearts = '❤️'.repeat(Math.ceil(game.loveMeter));
document.getElementById('score').textContent = `Level: ${game.level} | Score: ${game.score} | Love Meter: ${hearts}`;
requestAnimationFrame(gameLoop);
}
function startGame() {
game = {
score: 0,
levelScore: 0,
level: 1,
loveMeter: 3,
paused: false,
gameOver: false,
started: true,
particles: [],
hearts: [],
obstacles: [],
shakeTimer: 0,
drainFlash: 0,
combo: 0,
comboTimer: 0,
lastCollectTime: 0,
lineBonus: 0,
lineBonusTimer: 0,
graceUntil: Date.now() + 2500,
gardenFlowerCount: 0,
movementUnlocked: true,
lastCloseChime: 0
};
currentName = '';
document.getElementById('nameEntryOverlay').style.display = 'none';
document.getElementById('gameBtn').textContent = 'Pause';
document.getElementById('pauseOverlay').style.display = 'none';
const pauseText = document.getElementById('pauseText');
if (pauseText) pauseText.textContent = '⏸ PAUSED';
startBreakbeat();
// Healthy start: no overlap, clearly within safe distance
player1.x = 150;
player1.y = 200;
player2.x = 250;
player2.y = 200;
// Ensure no drift from previous run / held keys
keys = {};
player1.vx = 0;
player1.vy = 0;
player2.vx = 0;
player2.vy = 0;
if (player1.trail) player1.trail = [];
if (player2.trail) player2.trail = [];
document.getElementById('message').textContent = "";
gameLoop();
}
function togglePause() {
game.paused = !game.paused;
const gameBtn = document.getElementById('gameBtn');
const pauseOverlay = document.getElementById('pauseOverlay');
const pauseText = document.getElementById('pauseText');
if (game.paused) {
gameBtn.textContent = 'Resume';
if (pauseText) pauseText.textContent = '⏸ PAUSED';
pauseOverlay.style.display = 'flex';
// Keep music playing during pause - it's chill
} else {
gameBtn.textContent = 'Pause';
pauseOverlay.style.display = 'none';
if (!game.gameOver) {
gameLoop();
}
}
updateKonamiButtonPulse();
}
function handleGameButton() {
if (game.gameOver) {
// Game ended - restart
startGame();
} else if (!game.started) {
// Not started yet - start
startGame();
} else {
// Game running - toggle pause
togglePause();
}
}
function updateButtonState() {
const gameBtn = document.getElementById('gameBtn');
if (game.gameOver) {
gameBtn.textContent = 'Restart';
} else if (game.paused) {
gameBtn.textContent = 'Resume';
} else if (game.started) {
gameBtn.textContent = 'Pause';
} else {
gameBtn.textContent = 'Start';
}
updateKonamiButtonPulse();
}
const gameKeys = ['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'w', 'a', 's', 'd', 'W', 'A', 'S', 'D'];
// Check if music modal is open (disable game controls)
function isMusicModalOpen() {
const overlay = document.getElementById('musicSettingsOverlay');
return overlay && overlay.style.display === 'flex';
}
function isSequencerOpen() {
const overlay = document.getElementById('sequencerOverlay');
return overlay && overlay.style.display === 'flex';
}
document.addEventListener('keydown', (e) => {
resumeAudioIfNeeded();
// If sequencer is open, let inputs work normally
if (isSequencerOpen()) {
if (e.key === 'Escape') {
closeSequencer();
e.preventDefault();
}
return; // Don't process game keys
}
// If music modal is open, don't process game keys (let inputs work)
if (isMusicModalOpen()) {
// Allow Escape to close modal
if (e.key === 'Escape') {
closeMusicSettings();
e.preventDefault();
}
return;
}
// Konami code: only listen when paused or not started (or game over)
if (game && (game.paused || !game.started || game.gameOver)) {
const k = (e.key || '').toLowerCase();
if (k) {
const expected = KONAMI_SEQ[konamiPos];
if (k === expected) {
konamiPos++;
if (konamiPos >= KONAMI_SEQ.length) {
konamiPos = 0;
unlockKonamiBloom();
}
} else {
konamiPos = (k === KONAMI_SEQ[0]) ? 1 : 0;
}
}
}
keys[e.key] = true;
if (gameKeys.includes(e.key)) {
if (game && game.started && !game.gameOver) {
game.movementUnlocked = true;
}
e.preventDefault();
}
// P key for pause/resume
if ((e.key === 'p' || e.key === 'P') && game.started && !game.gameOver) {
togglePause();
}
});
document.addEventListener('keyup', (e) => {
keys[e.key] = false;
if (gameKeys.includes(e.key)) {
e.preventDefault();
}
});
// === MOBILE / TOUCH CONTROLS: drag each player ===
function getCanvasPos(e) {
const rect = canvas.getBoundingClientRect();
const x = (e.clientX - rect.left) * (canvas.width / rect.width);
const y = (e.clientY - rect.top) * (canvas.height / rect.height);
return { x, y };
}
function isTouchingPlayer(px, py, player) {
const dx = px - player.x;
const dy = py - player.y;
return Math.sqrt(dx * dx + dy * dy) <= (player.size * 0.75);
}
const activeDrags = new Map();
canvas.addEventListener('pointerdown', (e) => {
resumeAudioIfNeeded();
// Click to start or unpause
if (!game || !game.started) {
startGame();
return;
}
if (game.paused && !game.gameOver) {
togglePause();
return;
}
if (game.gameOver) return;
const { x, y } = getCanvasPos(e);
let target = null;
// Prefer closest if both overlap
const d1 = Math.hypot(x - player1.x, y - player1.y);
const d2 = Math.hypot(x - player2.x, y - player2.y);
const hit1 = isTouchingPlayer(x, y, player1);
const hit2 = isTouchingPlayer(x, y, player2);
if (hit1 && hit2) target = d1 <= d2 ? 'p1' : 'p2';
else if (hit1) target = 'p1';
else if (hit2) target = 'p2';
if (!target) return;
game.movementUnlocked = true;
activeDrags.set(e.pointerId, target);
canvas.setPointerCapture(e.pointerId);
e.preventDefault();
}, { passive: false });
canvas.addEventListener('pointermove', (e) => {
const target = activeDrags.get(e.pointerId);
if (!target) return;
const { x, y } = getCanvasPos(e);
const p = target === 'p1' ? player1 : player2;
p.x = Math.max(10, Math.min(canvas.width - 10, x));
p.y = Math.max(10, Math.min(canvas.height - 10, y));
p.vx = 0;
p.vy = 0;
e.preventDefault();
}, { passive: false });
function endPointer(e) {
if (activeDrags.has(e.pointerId)) {
activeDrags.delete(e.pointerId);
try { canvas.releasePointerCapture(e.pointerId); } catch (err) { }
}
}
canvas.addEventListener('pointerup', endPointer);
canvas.addEventListener('pointercancel', endPointer);
// === NAME ENTRY SYSTEM ===
const CHARS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
const EMOJIS = ['💖', '💞', '💕', '❤️', '💋', '🌹'];
const MAX_NAME_LENGTH = 10;
function initNameEntry() {
const charPicker = document.getElementById('charPicker');
const emojiRow = document.getElementById('emojiRow');
const specialRow = document.getElementById('specialRow');
// Character buttons (A-Z, 0-9 only - no special buttons here)
charPicker.innerHTML = '';
CHARS.split('').forEach(char => {
const btn = document.createElement('button');
btn.className = 'char-btn';
btn.textContent = char;
btn.onclick = () => addChar(char);
charPicker.appendChild(btn);
});
// Emoji buttons (6 emojis)
emojiRow.innerHTML = '';
EMOJIS.forEach(emoji => {
const btn = document.createElement('button');
btn.className = 'emoji-btn';
btn.textContent = emoji;
btn.onclick = () => addChar(emoji);
emojiRow.appendChild(btn);
});
// Special row: SPC and DEL (50/50 width)
specialRow.innerHTML = '';
const spaceBtn = document.createElement('button');
spaceBtn.className = 'char-btn special';
spaceBtn.textContent = 'SPC';
spaceBtn.onclick = () => addChar(' ');
specialRow.appendChild(spaceBtn);
const backBtn = document.createElement('button');
backBtn.className = 'char-btn special';
backBtn.textContent = '⌫ DEL';
backBtn.onclick = () => deleteChar();
specialRow.appendChild(backBtn);
}
function addChar(char) {
if (currentName.length < MAX_NAME_LENGTH) {
currentName += char;
updateNameDisplay();
}
}
function deleteChar() {
// Handle emoji deletion (they can be multi-byte)
const chars = [...currentName];
chars.pop();
currentName = chars.join('');
updateNameDisplay();
}
// === MARS LEAD (Playful, Childlike, Toy-ish) ===
// "Broken Toy" Organ / Melodica sound (Sawtooth/Square + Formant Filter + Tape Wobble)
function playMarsLead(time, note = 60, vol = 0.2, overrides = {}) {
const ctx = initAudio();
// Get params from leadParams.mars
const params = leadParams.mars || { wobble: 35, speed: 15, formant: 1200, click: 50, gain: 100 };
const vMult = (overrides.volMult || 1.0) * (params.gain / 100);
const finalVol = vol * vMult;
// Pitch wobbles (Tape / Dying Battery effects)
// Controlled by WOBBLE and SPEED params
const baseWobble = params.wobble || 35; // 0-100
const baseSpeed = (params.speed || 15) / 10; // 0.5-4.0 Hz
const driftRate = baseSpeed * (0.8 + Math.random() * 0.4); // Slight randomness
const driftDepth = 5 + (baseWobble * 0.5) + Math.random() * 10; // 5-60 cents based on wobble
// Frequencies
const slideAmt = overrides.slideAmount || 0;
const targetFreq = 440 * Math.pow(2, (note - 69) / 12);
// Oscillator 1: Sawtooth (Bright, reedy)
const osc1 = ctx.createOscillator();
osc1.type = 'sawtooth';
// Oscillator 2: Square (Hollow) - Octave Up for toy organ feel
const osc2 = ctx.createOscillator();
osc2.type = 'square';
// Pitch Envelope / Slide Logic
if (slideAmt !== 0) {
const startFreq = 440 * Math.pow(2, (note - 69 - slideAmt) / 12);
osc1.frequency.setValueAtTime(startFreq, time);
osc1.frequency.exponentialRampToValueAtTime(targetFreq, time + 0.15);
osc2.frequency.setValueAtTime(startFreq * 2, time);
osc2.frequency.exponentialRampToValueAtTime(targetFreq * 2, time + 0.15);
} else {
osc1.frequency.setValueAtTime(targetFreq, time);
osc2.frequency.setValueAtTime(targetFreq * 2, time); // Octave up
}
// Tape Wobble LFO
const lfo = ctx.createOscillator();
lfo.type = 'sine';
lfo.frequency.value = driftRate;
const lfoGain = ctx.createGain();
lfoGain.gain.value = driftDepth; // Detune amount in cents
lfo.connect(lfoGain);
lfoGain.connect(osc1.detune);
lfoGain.connect(osc2.detune);
// Filter: Formant-ish Bandpass (The "Mouth" of the toy)
// Controlled by FORMANT param
const formantFreq = params.formant || 1200;
const filter = ctx.createBiquadFilter();
filter.type = 'bandpass';
filter.frequency.value = formantFreq; // Nasal sweet spot (800-2500 Hz)
filter.Q.value = 0.8 + (formantFreq / 3000); // Slightly more resonant at higher formants
const hp = ctx.createBiquadFilter(); // Cut mud
hp.type = 'highpass';
hp.frequency.value = 300;
// Amp Envelope: Organ-like (Gate)
const gain = ctx.createGain();
const dur = overrides.decay || 0.15;
// Clicky attack (key mechanical noise simulation)
// Controlled by CLICK param (0-100)
const clickAmt = (params.click || 50) / 100; // 0-1
const clickPeak = 1 + (clickAmt * 0.5); // 1.0-1.5x multiplier
gain.gain.setValueAtTime(0.001, time);
gain.gain.linearRampToValueAtTime(finalVol * clickPeak, time + 0.005 + (0.01 * (1 - clickAmt))); // Click! (faster with more click)
gain.gain.linearRampToValueAtTime(finalVol, time + 0.03); // Sustain level
gain.gain.setValueAtTime(finalVol, time + dur - 0.05);
gain.gain.exponentialRampToValueAtTime(0.001, time + dur); // Quick release
// Mix
const mix1 = ctx.createGain(); mix1.gain.value = 0.5;
const mix2 = ctx.createGain(); mix2.gain.value = 0.3; // Lower volume for high octave
osc1.connect(mix1); mix1.connect(filter);
osc2.connect(mix2); mix2.connect(filter);
filter.connect(hp);
hp.connect(gain);
gain.connect(musicGain);
osc1.start(time);
osc2.start(time);
lfo.start(time);
osc1.stop(time + dur + 0.1);
osc2.stop(time + dur + 0.1);
lfo.stop(time + dur + 0.1);
}
// === TB-303 ACID BASS ===
// Classic acid house bass synth emulation
// Single saw, 18dB-ish filter, high resonance, accent, slide
// State for slide/portamento (mono voice behavior)
let last303Freq = null;
let last303Time = 0;
function play303(time, note = 36, vol = 0.25, overrides = {}) {
const ctx = initAudio();
// Options
const accent = overrides.accent || false; // Accent boost
const slide = overrides.slide || false; // Slide from last note
const decay = overrides.decay || 0.2; // Envelope decay
// Convert MIDI to frequency
const freq = 440 * Math.pow(2, (note - 69) / 12);
// Accent boosts both volume and filter
const accentVolMult = accent ? 1.4 : 1.0;
const accentFilterMult = accent ? 1.5 : 1.0;
const finalVol = vol * accentVolMult;
// === SINGLE SAWTOOTH OSCILLATOR ===
const osc = ctx.createOscillator();
osc.type = 'sawtooth';
// Slide logic: if slide enabled and we have a previous note, glide
const slideTime = 0.06; // 60ms slide (time-based like real 303)
if (slide && last303Freq && (time - last303Time) < 0.3) {
// Start at last frequency, glide to current
osc.frequency.setValueAtTime(last303Freq, time);
osc.frequency.exponentialRampToValueAtTime(freq, time + slideTime);
} else {
osc.frequency.setValueAtTime(freq, time);
}
// Store for next slide
last303Freq = freq;
last303Time = time;
// === 18dB/OCT FILTER (Approximate with 12dB + 6dB) ===
// Main resonant filter (the squelch!)
const filter1 = ctx.createBiquadFilter();
filter1.type = 'lowpass';
filter1.Q.value = 18 + (accent ? 8 : 0); // High resonance, higher on accent
// Second filter stage for steeper slope
const filter2 = ctx.createBiquadFilter();
filter2.type = 'lowpass';
filter2.Q.value = 2; // Lower Q, just adds slope
// Filter envelope - THE ACID SOUND
const filterBase = 200; // Closed filter
const filterPeak = (800 + freq * 2) * accentFilterMult; // Open filter (scales with note)
const filterDecay = decay * 0.8; // Filter closes faster than amp
// Fast attack, exponential decay
filter1.frequency.setValueAtTime(filterPeak, time);
filter1.frequency.exponentialRampToValueAtTime(filterBase, time + filterDecay);
// Second stage tracks but less extreme
filter2.frequency.setValueAtTime(filterPeak * 1.5, time);
filter2.frequency.exponentialRampToValueAtTime(filterBase * 2, time + filterDecay);
// === DISTORTION (Subtle warmth) ===
const shaper = ctx.createWaveShaper();
const curve = new Float32Array(256);
const drive = accent ? 2.5 : 1.5;
for (let i = 0; i < 256; i++) {
const x = (i - 128) / 128;
curve[i] = Math.tanh(x * drive);
}
shaper.curve = curve;
// === AMP ENVELOPE ===
const gain = ctx.createGain();
gain.gain.setValueAtTime(0.001, time);
gain.gain.linearRampToValueAtTime(finalVol, time + 0.005); // Very fast attack
gain.gain.setValueAtTime(finalVol, time + decay * 0.3); // Hold
gain.gain.exponentialRampToValueAtTime(0.001, time + decay); // Decay
// === CHAIN: Osc → Filter1 → Filter2 → Shaper → Gain → Out ===
osc.connect(filter1);
filter1.connect(filter2);
filter2.connect(shaper);
shaper.connect(gain);
gain.connect(musicGain);
// Play
osc.start(time);
osc.stop(time + decay + 0.1);
}
// Mars Lead Config Panel (New)
function createMarsConfig() {
// Create if doesn't exist (handled by static HTML usually, but we can inject if needed)
// For now, we assume user doesn't need deep config for this new hidden voice immediately,
// or we can add a simple placeholder in the HTML if requested.
}
function updateNameDisplay() {
const display = document.getElementById('nameDisplay');
display.textContent = currentName || '_';
}
function showNameEntry() {
document.getElementById('finalScore').textContent = game.score;
document.getElementById('finalLevel').textContent = game.level;
document.getElementById('nameEntryOverlay').style.display = 'flex';
// Hide canvas overlay behind the full-screen overlay to avoid flashing through
const pauseOverlay = document.getElementById('pauseOverlay');
if (pauseOverlay) pauseOverlay.style.display = 'none';
currentName = '';
updateNameDisplay();
// Start dubby riddim for name entry
startDubRiddim();
renderLeaderboard();
}
function submitScore() {
const name = currentName.trim() || '💖💖💖';
saveScore(name, game.score, game.level);
document.getElementById('nameEntryOverlay').style.display = 'none';
renderLeaderboard(game.score);
showLeaderboard();
}
function skipScore() {
document.getElementById('nameEntryOverlay').style.display = 'none';
stopDubRiddim();
// Restore game-over overlay after closing name entry (game is still over)
if (game && game.gameOver) {
const pauseOverlay = document.getElementById('pauseOverlay');
const pauseText = document.getElementById('pauseText');
if (pauseText) pauseText.textContent = 'ONE MORE CHANCE?';
if (pauseOverlay) pauseOverlay.style.display = 'flex';
}
// Resume main music if enabled
if (musicEnabled) {
startBreakbeat();
}
}
function showLeaderboard() {
renderLeaderboard();
document.getElementById('leaderboardOverlay').style.display = 'flex';
// Hide canvas overlay behind the full-screen leaderboard to avoid flashing through
const pauseOverlay = document.getElementById('pauseOverlay');
if (pauseOverlay) pauseOverlay.style.display = 'none';
// Pause main music, start space invaders
stopBreakbeat();
startDubRiddim();
}
function closeLeaderboard() {
document.getElementById('leaderboardOverlay').style.display = 'none';
stopDubRiddim();
// Restore pause overlay if game is paused or game over
if (game && game.gameOver) {
const pauseOverlay = document.getElementById('pauseOverlay');
const pauseText = document.getElementById('pauseText');
if (pauseText) pauseText.textContent = 'ONE MORE CHANCE?';
if (pauseOverlay) pauseOverlay.style.display = 'flex';
} else if (game && game.paused) {
const pauseOverlay = document.getElementById('pauseOverlay');
const pauseText = document.getElementById('pauseText');
if (pauseText) pauseText.textContent = '⏸ PAUSED';
if (pauseOverlay) pauseOverlay.style.display = 'flex';
}
// Resume main music if music is enabled (regardless of game state)
if (musicEnabled) {
startBreakbeat();
}
}
function startNewRunFromLeaderboard() {
gardenFlowers = [];
renderGarden();
if (game) game.gardenFlowerCount = 0;
startGame();
closeLeaderboard();
}
// Update scores button visibility based on leaderboard
function updateScoresButton() {
const scores = getLeaderboard();
const btn = document.getElementById('scoresBtn');
if (btn) {
btn.style.display = scores.length > 0 ? 'inline-block' : 'none';
}
}
// Initialize name entry on load
initNameEntry();
// === DRAG-AND-DROP COLLECTION LOADING ===
// Set up drag-and-drop handlers for collection JSON files
function setupDragAndDrop() {
const overlay = document.getElementById('sequencerOverlay');
const body = document.body;
// Prevent default drag behaviors
['dragenter', 'dragover', 'dragleave', 'drop'].forEach(eventName => {
body.addEventListener(eventName, preventDefaults, false);
if (overlay) {
overlay.addEventListener(eventName, preventDefaults, false);
}
});
function preventDefaults(e) {
e.preventDefault();
e.stopPropagation();
}
// Highlight drop zone on drag over
function highlight(e) {
if (overlay) overlay.style.border = '3px dashed #0FF';
}
function unhighlight(e) {
if (overlay) overlay.style.border = '';
}
['dragenter', 'dragover'].forEach(eventName => {
body.addEventListener(eventName, highlight, false);
if (overlay) overlay.addEventListener(eventName, highlight, false);
});
['dragleave', 'drop'].forEach(eventName => {
body.addEventListener(eventName, unhighlight, false);
if (overlay) overlay.addEventListener(eventName, unhighlight, false);
});
// Handle drop
function handleDrop(e) {
const dt = e.dataTransfer;
const files = dt.files;
if (files.length > 0) {
const file = files[0];
if (file.name.toLowerCase().endsWith('.json')) {
handleCollectionDrop(file);
} else {
logEvolution('DROP: Not a JSON file');
}
}
}
body.addEventListener('drop', handleDrop, false);
if (overlay) overlay.addEventListener('drop', handleDrop, false);
}
// Initialize drag-and-drop on page load
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', setupDragAndDrop);
} else {
setupDragAndDrop();
}
updateScoresButton();
// Initialize Director ($1010 DSL Engine)
const director = new Director(breakbeat);
breakbeat.director = director;
// Draw initial state so canvas isn't blank
function drawInitialState() {
ctx.fillStyle = colors.black;
ctx.fillRect(0, 0, canvas.width, canvas.height);
// Draw background pattern
ctx.fillStyle = colors.blue;
for (let i = 0; i < canvas.width; i += 40) {
for (let j = 0; j < canvas.height; j += 40) {
ctx.fillRect(i, j, 2, 2);
}
}
// Draw connection line FIRST (behind players)
ctx.strokeStyle = colors.red;
ctx.lineWidth = 2;
ctx.setLineDash([5, 5]);
ctx.beginPath();
ctx.moveTo(150 + 12, 200); // Start at edge of player 1
ctx.lineTo(250 - 12, 200); // End at edge of player 2
ctx.stroke();
ctx.setLineDash([]);
// Draw players at starting positions (on top of line)
drawPixelCharacter(150, 200, 20, colors.magenta, 1);
drawPixelCharacter(250, 200, 20, colors.cyan, 2);
}
drawInitialState();
document.getElementById('gameBtn').textContent = 'Start';
</script>
</body>
</html>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment