|
<!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;"><</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;">></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> |