MSX-MODULAR-TEST / index.html
SREAL's picture
Rename MS12 - Copy.html to index.html
4e9a6a1 verified
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>MS1-X MOUSE MODULATION INDEX</title>
<link href="https://fonts.googleapis.com/css2?family=Orbitron:wght@400;700&family=Share+Tech+Mono&display=swap" rel="stylesheet">
<script src="https://cdnjs.cloudflare.com/ajax/libs/tone/14.8.49/Tone.js"></script>
<style>
:root {
--synth-green: #39ff14;
--synth-red: #ff3939;
--bg-primary: #0a0c0e;
--bg-secondary: #1a1e23;
--bg-tertiary: #111417;
--bg-button: #141719;
--bg-gradient-start: #0f1419;
--bg-gradient-end: #080a0c;
--glow-primary: rgba(57, 255, 20, 0.2);
--glow-secondary: rgba(57, 255, 20, 0.1);
--glow-strong: rgba(57, 255, 20, 0.8);
--text-shadow: 0 0 8px var(--synth-green);
--box-shadow-primary: 0 0 8px var(--glow-primary);
--box-shadow-inset: inset 0 0 15px rgba(57, 255, 20, 0.1);
--border-radius: 8px;
--border-radius-small: 4px;
--transition: all 0.3s;
}
body {
margin: 0;
background: radial-gradient(circle at center, var(--bg-gradient-start) 0%, var(--bg-gradient-end) 100%);
color: var(--synth-green);
font-family: 'Share Tech Mono', monospace;
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
overflow: hidden;
}
.container {
width: 100%;
max-width: 1400px;
padding: 15px;
}
.header {
text-align: center;
margin-bottom: 20px;
position: relative;
}
.header:after {
content: '';
position: absolute;
bottom: -8px;
left: 50%;
transform: translateX(-50%);
width: 250px;
height: 2px;
background: linear-gradient(90deg, transparent, var(--synth-green), transparent);
}
.title {
font-family: 'Orbitron', sans-serif;
font-size: 60px;
margin: 0;
text-shadow: var(--text-shadow);
letter-spacing: 3px;
background: linear-gradient(180deg, var(--synth-green), #2ba80d);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
}
.subtitle {
font-size: 20px;
opacity: 0.8;
margin: 3px 0;
letter-spacing: 2px;
}
.main-content {
display: flex;
flex-direction: column;
align-items: center;
gap: 20px;
max-width: 1200px;
margin: 0 auto;
}
.control-layout {
display: grid;
grid-template-columns: 280px 480px 280px;
grid-template-rows: auto auto auto auto;
gap: 12px;
align-items: start;
}
.top-left {
grid-column: 1;
grid-row: 1;
}
.top-center {
grid-column: 2;
grid-row: 1;
}
.top-right {
grid-column: 3;
grid-row: 1;
}
.center {
grid-column: 2;
grid-row: 2;
}
.left-container {
grid-column: 1;
grid-row: 2;
}
.right-container {
grid-column: 3;
grid-row: 2;
}
.bottom-left {
grid-column: 1;
grid-row: 3;
}
.bottom-center {
grid-column: 1 / -1;
grid-row: 3;
}
.bottom-right {
grid-column: 3;
grid-row: 3;
}
.recording-full {
grid-column: 1 / -1;
grid-row: 4;
}
.control-panel {
background: linear-gradient(45deg, var(--bg-tertiary), var(--bg-secondary));
border: 1px solid var(--synth-green);
border-radius: var(--border-radius);
padding: 6px;
box-shadow: var(--box-shadow-primary);
}
.panel-title {
margin: 0 0 8px 0;
color: var(--synth-green);
text-shadow: var(--text-shadow);
font-size: 14px;
text-align: center;
}
.sound-selector {
min-height: 300px;
}
.wave-selector {
min-height: 120px;
}
.synth-controls {
min-height: 428px;
}
.effects-controls {
min-height: 200px;
}
.recording-controls {
min-height: 150px;
}
.sound-buttons {
display: flex;
gap: 8px;
flex-wrap: wrap;
justify-content: center;
}
.sound-btn {
background: linear-gradient(180deg, var(--bg-secondary), var(--bg-button));
border: 1px solid var(--synth-green);
color: var(--synth-green);
padding: 6px 12px;
font-family: 'Share Tech Mono', monospace;
cursor: pointer;
transition: var(--transition);
border-radius: var(--border-radius-small);
font-size: 11px;
flex: 1;
min-width: 70px;
}
.sound-btn:hover {
background: linear-gradient(180deg, var(--synth-green), #2ba80d);
color: var(--bg-primary);
}
.sound-btn.active {
background: var(--synth-green);
color: var(--bg-primary);
}
.wave-buttons {
display: flex;
gap: 12px;
justify-content: center;
flex-wrap: wrap;
}
.wave-btn {
background: linear-gradient(180deg, var(--bg-secondary), var(--bg-button));
border: 1px solid var(--synth-green);
color: var(--synth-green);
padding: 8px;
font-family: 'Share Tech Mono', monospace;
cursor: pointer;
transition: var(--transition);
border-radius: var(--border-radius-small);
font-size: 11px;
width: 50px;
height: 50px;
display: flex;
align-items: center;
justify-content: center;
}
.wave-btn:hover {
background: linear-gradient(180deg, var(--synth-green), #2ba80d);
color: var(--bg-primary);
}
.wave-btn.active {
background: var(--synth-green);
color: var(--bg-primary);
}
.wave-icon {
width: 30px;
height: 30px;
}
.sine-wave {
stroke: currentColor;
stroke-width: 2;
fill: none;
}
.sawtooth-wave {
stroke: currentColor;
stroke-width: 2;
fill: none;
}
.square-wave {
stroke: currentColor;
stroke-width: 2;
fill: none;
}
.triangle-wave {
stroke: currentColor;
stroke-width: 2;
fill: none;
}
.control-group {
flex: 1;
}
.control-label {
font-size: 11px;
margin-bottom: 4px;
opacity: 0.8;
text-align: center;
}
.slider-container {
display: flex;
align-items: center;
gap: 8px;
}
.slider {
flex: 1;
-webkit-appearance: none;
height: 5px;
background: linear-gradient(90deg, #1a1e23, #39ff14);
border-radius: 2px;
outline: none;
}
.slider::-webkit-slider-thumb {
-webkit-appearance: none;
width: 14px;
height: 14px;
background: #39ff14;
border-radius: 50%;
cursor: pointer;
box-shadow: 0 0 8px #39ff14;
}
.slider-value {
min-width: 35px;
text-align: right;
font-size: 11px;
}
.xy-container {
position: relative;
width: 400px;
height: 445px;
margin: 0 auto;
padding: 15px;
background: linear-gradient(45deg, #111417, #1a1e23);
border-radius: 15px;
box-shadow:
inset 0 0 40px rgba(57, 255, 20, 0.1),
0 0 15px rgba(0,0,0,0.5);
}
.xy-pad {
width: 100%;
height: 100%;
background: #0a0c0e;
border: 2px solid #39ff14;
border-radius: 12px;
position: relative;
box-shadow:
0 0 15px #39ff1440,
inset 0 0 40px rgba(57, 255, 20, 0.1);
overflow: hidden;
}
.xy-cursor {
width: 14px;
height: 14px;
background: #39ff14;
border-radius: 50%;
position: absolute;
transform: translate(-50%, -50%);
pointer-events: none;
box-shadow:
0 0 15px #39ff14,
0 0 30px #39ff14,
0 0 45px #39ff14;
z-index: 10;
}
.grid-lines {
position: absolute;
width: 100%;
height: 100%;
background-image:
radial-gradient(circle, rgba(57, 255, 20, 0.1) 1px, transparent 1px),
linear-gradient(rgba(57, 255, 20, 0.1) 1px, transparent 1px),
linear-gradient(90deg, rgba(57, 255, 20, 0.1) 1px, transparent 1px);
background-size:
15px 15px,
40px 40px,
40px 40px;
opacity: 0.4;
}
.oscilloscope {
position: absolute;
width: 100%;
height: 100%;
z-index: 5;
}
.scanline {
position: absolute;
width: 100%;
height: 2px;
background: rgba(57, 255, 20, 0.1);
animation: scanline 2s linear infinite;
z-index: 4;
pointer-events: none;
}
@keyframes scanline {
0% {
transform: translateY(0);
}
100% {
transform: translateY(100%);
}
}
.effects-grid {
display: flex;
gap: 12px;
justify-content: center;
}
.effect-group {
background: linear-gradient(45deg, #111417, #1a1e23);
border: 1px solid #39ff14;
border-radius: 8px;
padding: 10px;
box-shadow: 0 0 8px rgba(57, 255, 20, 0.2);
min-height: 80px;
flex: 1;
}
.effect-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
}
.toggle {
background: linear-gradient(180deg, #1a1e23, #141719);
border: 1px solid #39ff14;
color: #39ff14;
padding: 4px 8px;
font-family: 'Share Tech Mono', monospace;
cursor: pointer;
transition: all 0.3s;
border-radius: 4px;
text-shadow: 0 0 4px #39ff14;
box-shadow:
0 0 8px rgba(57, 255, 20, 0.2),
inset 0 0 15px rgba(57, 255, 20, 0.1);
font-size: 10px;
}
.toggle:hover {
background: linear-gradient(180deg, #39ff14, #2ba80d);
color: #0a0c0e;
text-shadow: none;
}
.toggle.active {
background: #39ff14;
color: #0a0c0e;
box-shadow:
0 0 15px #39ff14,
inset 0 0 8px rgba(0,0,0,0.2);
}
.record-btn {
background: linear-gradient(180deg, #ff3939, #b82b2b);
border: 1px solid #ff3939;
color: white;
padding: 10px 20px;
font-family: 'Share Tech Mono', monospace;
cursor: pointer;
transition: all 0.3s;
border-radius: 4px;
text-shadow: 0 0 4px #ff3939;
box-shadow:
0 0 8px rgba(255, 57, 57, 0.2),
inset 0 0 15px rgba(255, 57, 57, 0.1);
font-size: 12px;
width: 100%;
margin-bottom: 15px;
}
.record-btn:hover {
background: linear-gradient(180deg, #ff6b6b, #e63939);
}
.record-btn.recording {
background: linear-gradient(180deg, #ff3939, #b82b2b);
animation: pulse 1s infinite;
}
@keyframes pulse {
0% { box-shadow: 0 0 8px rgba(255, 57, 57, 0.2), inset 0 0 15px rgba(255, 57, 57, 0.1); }
50% { box-shadow: 0 0 25px rgba(255, 57, 57, 0.8), inset 0 0 25px rgba(255, 57, 57, 0.3); }
100% { box-shadow: 0 0 8px rgba(255, 57, 57, 0.2), inset 0 0 15px rgba(255, 57, 57, 0.1); }
}
.waveform-container {
height: 80px;
background: #0a0c0e;
border: 1px solid #39ff14;
border-radius: 4px;
position: relative;
margin-bottom: 8px;
}
.waveform {
width: 100%;
height: 100%;
position: relative;
}
.playhead {
position: absolute;
top: 0;
width: 2px;
height: 100%;
background: #39ff14;
z-index: 10;
cursor: ew-resize;
}
.playhead-handle {
position: absolute;
top: -6px;
left: -6px;
width: 14px;
height: 14px;
background: #39ff14;
border-radius: 50%;
cursor: ew-resize;
box-shadow: 0 0 8px #39ff14;
z-index: 15;
}
.playhead-handle:hover {
box-shadow: 0 0 12px #39ff14;
}
.selection {
position: absolute;
top: 0;
height: 100%;
background: rgba(57, 255, 20, 0.2);
border-left: 1px solid #39ff14;
border-right: 1px solid #39ff14;
}
/* Recording Layout */
.recording-layout {
position: relative;
width: 100%;
}
.waveform-section {
position: relative;
width: 100%;
}
.control-icons {
position: absolute;
top: 8px;
left: 8px;
display: flex;
flex-direction: row;
gap: 6px;
align-items: center;
}
.icon-btn {
background: linear-gradient(180deg, #1a1e23, #141719);
border: 1px solid #39ff14;
color: #39ff14;
width: 30px;
height: 24px;
border-radius: 4px;
cursor: pointer;
transition: all 0.3s;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 0 8px rgba(57, 255, 20, 0.2);
font-size: 12px;
}
.icon-btn:hover {
background: linear-gradient(180deg, #39ff14, #2ba80d);
color: #0a0c0e;
box-shadow: 0 0 15px #39ff14;
}
.icon-btn:active {
transform: scale(0.95);
}
.record-icon {
background: linear-gradient(180deg, #ff3939, #b82b2b);
border-color: #ff3939;
color: white;
box-shadow: 0 0 8px rgba(255, 57, 57, 0.2);
}
.record-icon:hover {
background: linear-gradient(180deg, #ff6b6b, #e63939);
box-shadow: 0 0 15px #ff3939;
}
.record-icon.recording {
animation: pulse 1s infinite;
}
/* ADSR Editor Styles */
.adsr-window {
background: #0a0c0e;
border: 2px solid #39ff14;
border-radius: 8px;
padding: 10px;
margin-bottom: 10px;
box-shadow: 0 0 10px rgba(57, 255, 20, 0.3);
}
.link-btn {
background: linear-gradient(180deg, #1a1e23, #141719);
border: 1px solid #39ff14;
color: #39ff14;
padding: 4px 8px;
font-family: 'Share Tech Mono', monospace;
cursor: pointer;
transition: all 0.3s;
border-radius: 4px;
font-size: 10px;
margin-left: 10px;
}
.link-btn:hover {
background: linear-gradient(180deg, #39ff14, #2ba80d);
color: #0a0c0e;
}
.link-btn.active {
background: #ff8c00;
color: #0a0c0e;
box-shadow: 0 0 15px #ff8c00;
}
.adsr-tabs {
display: flex;
margin-bottom: 10px;
}
.adsr-tab {
background: #1a1e23;
border: 1px solid #39ff14;
color: #39ff14;
padding: 4px 8px;
cursor: pointer;
flex: 1;
text-align: center;
font-size: 10px;
font-family: 'Share Tech Mono', monospace;
}
.adsr-tab:hover {
background: linear-gradient(180deg, #39ff14, #2ba80d);
color: #0a0c0e;
}
.adsr-tab.active {
background: #39ff14;
color: #0a0c0e;
}
.adsr-tab.hidden {
display: none;
}
.adsr-editor {
background: #000;
border: 1px solid #39ff14;
padding: 10px;
}
.adsr-canvas {
width: 100%;
height: 80px;
background: #000;
border: 1px solid #39ff14;
margin-bottom: 10px;
}
.adsr-controls {
display: flex;
gap: 5px;
justify-content: space-between;
}
.adsr-slider {
width: 40px;
height: 10px;
writing-mode: bt-lr;
-webkit-appearance: none;
background: linear-gradient(to top, #1a1e23, #093f04);
border-radius: 2px;
outline: none;
}
.adsr-slider::-webkit-slider-thumb {
-webkit-appearance: none;
width: 16px;
height: 8px;
background: #39ff14;
border-radius: 0px;
cursor: pointer;
box-shadow: 0 0 8px #39ff14;
}
.filter-type-selector {
margin-top: 10px;
}
.filter-type-selector select {
background: #1a1e23;
border: 1px solid #39ff14;
color: #39ff14;
padding: 2px;
font-size: 10px;
font-family: 'Share Tech Mono', monospace;
width: 100%;
}
.filter-controls {
/* Inherits from control-group styles */
}
.param-select {
background: #1a1e23;
border: 1px solid #39ff14;
color: #39ff14;
padding: 2px 4px;
font-size: 10px;
font-family: 'Share Tech Mono', monospace;
width: 100%;
margin-bottom: 4px;
}
.param-select:focus {
outline: none;
box-shadow: 0 0 8px #39ff14;
}
.modulation-controls {
margin-top: 10px;
}
.compressor-attack-release {
display: flex;
gap: 5px;
justify-content: space-between;
}
.compressor-slider-container {
display: flex;
flex-direction: column;
align-items: center;
gap: 2px;
}
.compressor-slider-container .control-label {
font-size: 10px;
margin-bottom: 0;
}
.compressor-slider-container .slider {
width: 100%;
}
.compressor-value {
font-size: 9px;
color: #39ff14;
opacity: 0.8;
text-align: center;
}
.slider:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.effect-group.effect-off {
opacity: 0.5;
}
/* MIDI Editor Styles */
.midi-editor {
background: #0a0c0e;
border: 2px solid #39ff14;
border-radius: 8px;
padding: 10px;
margin-bottom: 10px;
box-shadow: 0 0 10px rgba(57, 255, 20, 0.3);
}
.midi-tabs {
display: flex;
margin-bottom: 10px;
}
.midi-tab {
background: #1a1e23;
border: 1px solid #39ff14;
color: #39ff14;
padding: 4px 8px;
cursor: pointer;
flex: 1;
text-align: center;
font-size: 10px;
font-family: 'Share Tech Mono', monospace;
}
.midi-tab:hover {
background: linear-gradient(180deg, #39ff14, #2ba80d);
color: #0a0c0e;
}
.midi-tab.active {
background: #39ff14;
color: #0a0c0e;
}
.midi-controls {
display: flex;
gap: 8px;
margin-bottom: 10px;
flex-wrap: wrap;
}
.midi-control-group {
display: flex;
flex-direction: column;
gap: 4px;
min-width: 60px;
}
.midi-control-group label {
font-size: 9px;
color: #39ff14;
opacity: 0.8;
text-align: center;
}
.midi-select {
background: #1a1e23;
border: 1px solid #39ff14;
color: #39ff14;
padding: 2px 4px;
font-size: 10px;
font-family: 'Share Tech Mono', monospace;
border-radius: 3px;
}
.midi-transport {
display: flex;
gap: 6px;
justify-content: center;
margin-bottom: 10px;
}
.midi-transport-btn {
background: linear-gradient(180deg, #1a1e23, #141719);
border: 1px solid #39ff14;
color: #39ff14;
width: 24px;
height: 24px;
border-radius: 4px;
cursor: pointer;
transition: all 0.3s;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 0 8px rgba(57, 255, 20, 0.2);
font-size: 10px;
}
.midi-transport-btn:hover {
background: linear-gradient(180deg, #39ff14, #2ba80d);
color: #0a0c0e;
box-shadow: 0 0 15px #39ff14;
}
.midi-transport-btn.playing {
background: #39ff14;
color: #0a0c0e;
animation: pulse 1s infinite;
}
.piano-roll-container {
position: relative;
background: #000;
border: 1px solid #39ff14;
border-radius: 4px;
overflow: hidden;
}
.piano-roll-canvas {
background: #0a0c0e;
display: block;
}
.piano-roll-grid {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-image:
linear-gradient(rgba(57, 255, 20, 0.1) 1px, transparent 1px),
linear-gradient(90deg, rgba(57, 255, 20, 0.1) 1px, transparent 1px);
background-size: 20px 20px;
pointer-events: none;
}
.piano-roll-navigation {
display: flex;
gap: 8px;
margin-top: 8px;
}
.nav-slider-container {
display: flex;
align-items: center;
gap: 6px;
flex: 1;
}
.nav-slider-container.vertical {
flex-direction: column;
width: 20px;
height: 120px;
}
.nav-slider-container.horizontal {
flex-direction: row;
height: 20px;
width: 100%;
}
.nav-slider {
flex: 1;
-webkit-appearance: none;
background: linear-gradient(90deg, #1a1e23, #39ff14);
border-radius: 2px;
outline: none;
}
.nav-slider::-webkit-slider-thumb {
-webkit-appearance: none;
width: 12px;
height: 12px;
background: #39ff14;
border-radius: 50%;
cursor: pointer;
box-shadow: 0 0 6px #39ff14;
}
.nav-slider.vertical {
width: 8px;
height: 100px;
writing-mode: bt-lr;
}
.nav-slider.horizontal {
width: 100%;
height: 8px;
}
.nav-label {
font-size: 8px;
color: #39ff14;
opacity: 0.7;
text-align: center;
min-width: 30px;
}
.midi-generation {
margin-top: 10px;
padding-top: 10px;
border-top: 1px solid rgba(57, 255, 20, 0.3);
}
.midi-generation-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
margin-bottom: 8px;
}
.midi-generation-btn {
background: linear-gradient(180deg, #1a1e23, #141719);
border: 1px solid #39ff14;
color: #39ff14;
padding: 4px 8px;
font-family: 'Share Tech Mono', monospace;
cursor: pointer;
transition: all 0.3s;
border-radius: 4px;
font-size: 9px;
text-align: center;
}
.midi-generation-btn:hover {
background: linear-gradient(180deg, #39ff14, #2ba80d);
color: #0a0c0e;
}
.midi-generation-btn.active {
background: #39ff14;
color: #0a0c0e;
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1 class="title">MS1-BX</h1>
</div>
<div class="main-content">
<div class="control-layout">
<!-- Top Left: Sound Selector -->
<div class="control-panel top-left">
<h3 class="panel-title">SOUND GENERATION</h3>
<div class="sound-buttons">
<button class="sound-btn active" data-sound="fm">FM SYNTH</button>
<button class="sound-btn" data-sound="additive">ADDITIVE</button>
</div>
</div>
<!-- Top Center: Wave Selector -->
<div class="control-panel top-center">
<h3 class="panel-title">OSCILLATOR WAVEFORM</h3>
<div class="wave-buttons">
<button class="wave-btn active" data-wave="sine">
<svg class="wave-icon" viewBox="0 0 30 30">
<path class="sine-wave" d="M2,15 C8,5 12,25 18,15 C24,5 28,25 32,15" />
</svg>
</button>
<button class="wave-btn" data-wave="sawtooth">
<svg class="wave-icon" viewBox="0 0 30 30">
<path class="sawtooth-wave" d="M2,25 L15,5 L15,25 L28,5" />
</svg>
</button>
<button class="wave-btn" data-wave="square">
<svg class="wave-icon" viewBox="0 0 30 30">
<path class="square-wave" d="M2,25 L2,5 L15,5 L15,25 L28,25 L28,5" />
</svg>
</button>
<button class="wave-btn" data-wave="triangle">
<svg class="wave-icon" viewBox="0 0 30 30">
<path class="triangle-wave" d="M2,25 L15,5 L28,25" />
</svg>
</button>
</div>
</div>
<!-- Top Right: Note Controls -->
<div class="control-panel top-right">
<h3 class="panel-title">NOTE CONTROLS</h3>
<div class="note-controls">
<div class="note-control-group">
<div class="control-label">GLIDE TIME [FM ONLY]</div>
<div class="slider-container">
<input type="range" class="slider" id="glideTime" min="0" max="5" step="0.01" value="0">
<span class="slider-value" id="glideTimeValue">0.00s</span>
</div>
</div>
</div>
</div>
<!-- Center: XY Pad -->
<div class="control-panel center">
<div class="xy-container">
<div class="xy-pad">
<div class="grid-lines"></div>
<div class="scanline"></div>
<canvas class="oscilloscope"></canvas>
<div class="xy-cursor"></div>
</div>
</div>
</div>
<!-- Left Container -->
<div class="control-panel left-container synth-controls">
<h3 class="panel-title">ENVELOPE/MODULATION</h3>
<!-- ADSR Window -->
<div class="adsr-window">
<div class="adsr-tabs">
<button class="adsr-tab active" data-envelope="amp">AMP</button>
<button class="adsr-tab" data-envelope="filter">FILTER</button>
<button class="adsr-tab" data-envelope="modulation">MOD</button>
<button class="link-btn" id="linkBtn">LINK</button>
</div>
<div class="adsr-editor">
<canvas class="adsr-canvas" width="200" height="80"></canvas>
<div class="adsr-controls">
<div class="control-group">
<div class="control-label">A</div>
<input type="range" class="adsr-slider" id="attack" min="0.01" max="2" step="0.01" value="0.01">
</div>
<div class="control-group">
<div class="control-label">D</div>
<input type="range" class="adsr-slider" id="decay" min="0.01" max="2" step="0.01" value="0.2">
</div>
<div class="control-group">
<div class="control-label">S</div>
<input type="range" class="adsr-slider" id="sustain" min="0" max="1" step="0.01" value="0.8">
</div>
<div class="control-group">
<div class="control-label">R</div>
<input type="range" class="adsr-slider" id="release" min="0.01" max="4" step="0.01" value="1">
</div>
</div>
<div class="filter-type-selector">
<select id="filterType">
<option value="lowpass">Lowpass</option>
<option value="highpass">Highpass</option>
<option value="bandpass">Bandpass</option>
<option value="notch">Notch</option>
</select>
</div>
</div>
</div>
<!-- Filter Controls -->
<div class="filter-controls">
<div class="control-group">
<div class="control-label">CUTOFF</div>
<div class="slider-container">
<input type="range" class="slider" id="filterCutoff" min="20" max="20000" value="1000">
<span class="slider-value" id="filterCutoffValue">1000Hz</span>
</div>
</div>
<div class="control-group">
<div class="control-label">RESONANCE</div>
<div class="slider-container">
<input type="range" class="slider" id="filterResonance" min="0.1" max="20" step="0.1" value="1">
<span class="slider-value" id="filterResonanceValue">1.0</span>
</div>
</div>
<div class="control-group">
<div class="control-label">ENVELOPE AMOUNT</div>
<div class="slider-container">
<input type="range" class="slider" id="filterEnvelopeAmount" min="0" max="100" value="100">
<span class="slider-value" id="filterEnvelopeAmountValue">100%</span>
</div>
</div>
</div>
<!-- X/Y Modulation Controls -->
<div class="modulation-controls">
<div class="control-group">
<div class="control-label">X MODULATION</div>
<select class="param-select" id="xParamSelect">
<option value="modulationIndex">Modulation Index</option>
</select>
<div class="slider-container">
<input type="range" class="slider" id="xModulation" min="0" max="100" value="10">
<span class="slider-value" id="xModulationValue">10</span>
</div>
</div>
<div class="control-group">
<div class="control-label">Y MODULATION</div>
<select class="param-select" id="yParamSelect">
<option value="harmonicity">Harmonicity</option>
</select>
<div class="slider-container">
<input type="range" class="slider" id="yModulation" min="0" max="10" value="3">
<span class="slider-value" id="yModulationValue">3.0</span>
</div>
</div>
</div>
</div>
<!-- Right Container -->
<div class="control-panel right-container">
<h3 class="panel-title">RIGHT CONTROLS</h3>
</div>
<!-- Bottom Center: Effects -->
<div class="control-panel bottom-center">
<h3 class="panel-title">EFFECTS</h3>
<div class="effects-grid">
<div class="effect-group">
<div class="effect-header">
<span id="distortionHeader">DISTORTION: Clean</span>
<button class="toggle" data-effect="distortion">OFF</button>
</div>
<div class="slider-container">
<span class="control-label">PRESET</span>
<input type="range" class="slider" id="distortionPreset" min="1" max="8" value="1">
</div>
<div class="slider-container">
<span class="control-label">MIX</span>
<input type="range" class="slider" id="distortionMix" min="0" max="100" value="20">
<span class="slider-value" id="distortionMixValue">20%</span>
</div>
</div>
<div class="effect-group">
<div class="effect-header">
<span>CHORUS</span>
<button class="toggle" data-effect="chorus">OFF</button>
</div>
<div class="slider-container">
<span class="control-label">MIX</span>
<input type="range" class="slider" id="chorusMix" min="0" max="100" value="40">
<span class="slider-value" id="chorusMixValue">40%</span>
</div>
<div class="slider-container">
<span class="control-label">DEPTH</span>
<input type="range" class="adsr-slider" id="chorusDepth" min="0" max="1" step="0.01" value="0.5">
<span class="slider-value" id="chorusDepthValue">0.50</span>
</div>
<div class="slider-container">
<span class="control-label">SPREAD</span>
<input type="range" class="adsr-slider" id="chorusSpread" min="0" max="180" step="1" value="180">
<span class="slider-value" id="chorusSpreadValue">180°</span>
</div>
<div class="slider-container">
<span class="control-label">FREQUENCY</span>
<input type="range" class="adsr-slider" id="chorusFrequency" min="0.1" max="10" step="0.1" value="4">
<span class="slider-value" id="chorusFrequencyValue">4.0Hz</span>
</div>
</div>
<div class="effect-group">
<div class="effect-header">
<span>REVERB</span>
<button class="toggle" data-effect="reverb">OFF</button>
</div>
<div class="slider-container">
<span class="control-label">MIX</span>
<input type="range" class="slider" id="reverbMix" min="0" max="100" value="50">
<span class="slider-value" id="reverbMixValue">50%</span>
</div>
<div class="slider-container">
<span class="control-label">DECAY</span>
<input type="range" class="adsr-slider" id="reverbDecay" min="0.1" max="10" step="0.1" value="2">
<span class="slider-value" id="reverbDecayValue">2.0s</span>
</div>
<div class="slider-container">
<span class="control-label">PRE DELAY</span>
<input type="range" class="adsr-slider" id="reverbPreDelay" min="0" max="1" step="0.01" value="0.01">
<span class="slider-value" id="reverbPreDelayValue">0.01s</span>
</div>
</div>
<div class="effect-group">
<div class="effect-header">
<span>COMPRESSOR</span>
<button class="toggle" data-effect="compressor">OFF</button>
</div>
<div class="slider-container">
<span class="control-label">RATIO</span>
<input type="range" class="slider" id="compressorRatio" min="1" max="20" step="0.1" value="4">
<span class="slider-value" id="compressorRatioValue">4:1</span>
</div>
<div class="slider-container">
<span class="control-label">THRESHOLD</span>
<input type="range" class="slider" id="compressorThreshold" min="-100" max="0" step="1" value="-24">
<span class="slider-value" id="compressorThresholdValue">-24dB</span>
</div>
<div class="slider-container">
<span class="control-label">ATTACK</span>
<input type="range" class="adsr-slider" id="compressorAttack" min="0.001" max="1" step="0.001" value="0.1">
<span class="slider-value" id="compressorAttackValue">0.10s</span>
</div>
<div class="slider-container">
<span class="control-label">RELEASE</span>
<input type="range" class="adsr-slider" id="compressorRelease" min="0.05" max="1" step="0.01" value="0.2">
<span class="slider-value" id="compressorReleaseValue">0.20s</span>
</div>
</div>
</div>
</div>
<!-- Full Width Recording -->
<div class="control-panel recording-full">
<h3 class="panel-title">RECORDING</h3>
<div class="recording-layout">
<div class="waveform-section">
<div class="waveform-container">
<canvas class="waveform" id="waveformCanvas"></canvas>
<div class="playhead" id="playhead" style="left: 0%;"></div>
<div class="playhead-handle" id="playheadHandle"></div>
<div class="selection" id="selection" style="display: none;"></div>
</div>
</div>
<div class="control-icons">
<button class="icon-btn record-icon" id="recordBtn" title="Start/Stop Recording">
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
<circle cx="12" cy="12" r="10"/>
<circle cx="12" cy="12" r="3"/>
</svg>
</button>
<button class="icon-btn play-icon" id="playSample" title="Play Sample">
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
<polygon points="8,5 19,12 8,19"/>
</svg>
</button>
<button class="icon-btn export-icon" id="exportWav" title="Export WAV">
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
<path d="M19,9h-4V3H9v6H5l7,7L19,9z M5,18v2h14v-2H5z"/>
</svg>
</button>
<button class="icon-btn slice-icon" id="sliceBtn" title="Slice Audio">
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
<path d="M9.64,7.64c0.23-0.5,0.36-1.05,0.36-1.64c0-2.21-1.79-4-4-4S2,3.79,2,6s1.79,4,4,4c0.59,0,1.14-0.13,1.64-0.36L10,12l-2.36,2.36 C7.14,14.13,6.59,14,6,14c-2.21,0-4,1.79-4,4s1.79,4,4,4s4-1.79,4-4c0-0.59-0.13-1.14-0.36-1.64L12,14l7,7h3v-2L9.64,7.64z"/>
</svg>
</button>
</div>
</div>
</div>
</div>
</div>
</div>
<script>
// ===========================================
// AUDIO ENGINE SETUP
// ===========================================
let currentSynth = null;
let isRecording = false;
let waveformData = [];
let sampleBuffer = null;
let currentWaveform = 'sine';
let glideTime = 0;
// Initialize FM Synth
const fmSynth = new Tone.FMSynth({
modulationIndex: 10,
harmonicity: 3,
envelope: {
attack: 0.01,
decay: 0.2,
sustain: 0.8,
release: 1
},
modulationEnvelope: {
attack: 0.01,
decay: 0.2,
sustain: 0.8,
release: 1
}
});
// Initialize Additive Synth with DuoSynth
const additiveSynth = new Tone.PolySynth(Tone.DuoSynth, {
vibratoAmount: 0,
vibratoRate: 5,
harmonicity: 1,
voice0: {
oscillator: { type: 'sine' },
envelope: {
attack: 0.01,
decay: 0.2,
sustain: 0.8,
release: 1
}
},
voice1: {
oscillator: { type: 'sine' },
envelope: {
attack: 0.01,
decay: 0.2,
sustain: 0.8,
release: 1
}
},
maxPolyphony: 16
});
const filter = new Tone.Filter({
frequency: 1000,
type: 'lowpass',
Q: 1,
gain: 0
});
const filterEnvelope = new Tone.FrequencyEnvelope({
attack: 0.01,
decay: 0.2,
sustain: 0.8,
release: 1,
baseFrequency: 1000,
octaves: 4
});
filterEnvelope.connect(filter.frequency);
// ===========================================
// EFFECTS CHAIN
// ===========================================
const compressor = new Tone.Compressor({
ratio: 4,
threshold: -24,
attack: 0.1,
release: 0.2
});
const chorus = new Tone.Chorus({
frequency: 4,
delayTime: 2.5,
depth: 0.5,
wet: 0
}).start();
const reverb = new Tone.Reverb({
decay: 2,
wet: 0
});
// Guitar Amp Distortion Presets
const distortionPresets = [
{ name: 'Clean', chebyshev1: null, chebyshev2: null, gain: 1, feedback: false },
{ name: 'Crunch', chebyshev1: { order: 2, wet: 0.3 }, chebyshev2: null, gain: 1.2, feedback: false },
{ name: 'OD', chebyshev1: { order: 4, wet: 0.4 }, chebyshev2: null, gain: 1.5, feedback: false },
{ name: 'Blood', chebyshev1: { order: 6, wet: 0.6 }, chebyshev2: { order: 2, wet: 0.2 }, gain: 2, feedback: false },
{ name: 'Heavy', chebyshev1: { order: 8, wet: 0.7 }, chebyshev2: { order: 4, wet: 0.3 }, gain: 2.5, feedback: false },
{ name: 'Fuzz', chebyshev1: { order: 10, wet: 0.8 }, chebyshev2: { order: 6, wet: 0.4 }, gain: 3, feedback: false },
{ name: 'Scream', chebyshev1: { order: 12, wet: 0.9 }, chebyshev2: { order: 8, wet: 0.5 }, gain: 3.5, feedback: true },
{ name: 'Extreme', chebyshev1: { order: 14, wet: 1.0 }, chebyshev2: { order: 10, wet: 0.6 }, gain: 4, feedback: true }
];
// Initialize distortion effects
let currentDistortionPreset = 0;
const chebyshev1 = new Tone.Chebyshev({ order: 2, wet: 0 });
const chebyshev2 = new Tone.Chebyshev({ order: 4, wet: 0 });
const distortionGain = new Tone.Gain(1);
const distortionFeedback = new Tone.FeedbackDelay({
delayTime: 0.01,
feedback: 0.3,
wet: 0
});
// Distortion chain: input -> gain -> chebyshev1 -> chebyshev2 -> feedback -> output
const distortionChain = [distortionGain, chebyshev1, chebyshev2, distortionFeedback];
// Effects chain
const effectsChain = [...distortionChain, chorus, reverb, compressor];
// Compressor bypass flag
let compressorBypassed = true;
// Analyzer for oscilloscope
const analyzer = new Tone.Analyser('waveform', 256);
// Initialize with FM Synth
currentSynth = fmSynth;
currentSynth.chain(filter, ...effectsChain, analyzer, Tone.Destination);
// Initially bypass compressor
compressor.disconnect(analyzer);
reverb.connect(analyzer);
// ===========================================
// RECORDING SETUP
// ===========================================
// Create Tone.Recorder and connect to last effect in chain (reverb)
const toneRecorder = new Tone.Recorder();
reverb.connect(toneRecorder);
// MIDI Support
let activeMIDINotes = [];
if (navigator.requestMIDIAccess) {
navigator.requestMIDIAccess().then(onMIDISuccess, onMIDIFailure);
}
function onMIDISuccess(midiAccess) {
for (let input of midiAccess.inputs.values()) {
input.onmidimessage = onMIDIMessage;
}
}
function onMIDIFailure() {
console.log('MIDI access failed');
}
function fmMidiHandler(command, note, velocity) {
if (command === 144 && velocity > 0) { // Note on
if (!activeMIDINotes.includes(note)) {
activeMIDINotes.push(note);
}
const freq = Tone.Frequency(note, 'midi').toFrequency();
if (activeMIDINotes.length === 1) {
fmSynth.triggerAttack(freq);
filterEnvelope.triggerAttack();
} else if (glideTime > 0) {
fmSynth.frequency.rampTo(freq, glideTime);
} else {
fmSynth.frequency.setValueAtTime(freq, Tone.now());
}
} else if (command === 128 || (command === 144 && velocity === 0)) { // Note off
const index = activeMIDINotes.indexOf(note);
if (index !== -1) {
activeMIDINotes.splice(index, 1);
if (activeMIDINotes.length === 0) {
fmSynth.triggerRelease();
filterEnvelope.triggerRelease();
} else {
// Set to first held note
const firstNote = activeMIDINotes[0];
const firstFreq = Tone.Frequency(firstNote, 'midi').toFrequency();
if (glideTime > 0) {
fmSynth.frequency.rampTo(firstFreq, glideTime);
} else {
fmSynth.frequency.setValueAtTime(firstFreq, Tone.now());
}
}
}
}
}
function additiveMidiHandler(command, note, velocity) {
const freq = Tone.Frequency(note, 'midi').toFrequency();
if (command === 144 && velocity > 0) { // Note on
if (activeMIDINotes.length === 0) {
filterEnvelope.triggerAttack();
}
activeMIDINotes.push(note);
additiveSynth.triggerAttack(freq, Tone.now(), velocity / 127);
} else if (command === 128 || (command === 144 && velocity === 0)) { // Note off
const index = activeMIDINotes.indexOf(note);
if (index !== -1) {
activeMIDINotes.splice(index, 1);
}
additiveSynth.triggerRelease(freq, Tone.now());
if (activeMIDINotes.length === 0) {
filterEnvelope.triggerRelease();
}
}
}
function fmKeyboardHandler(key, freq) {
if (!pressedKeys.includes(key)) {
pressedKeys.push(key);
if (pressedKeys.length === 1) {
fmSynth.triggerAttack(freq);
filterEnvelope.triggerAttack();
} else if (glideTime > 0) {
fmSynth.frequency.rampTo(freq, glideTime);
} else {
fmSynth.frequency.setValueAtTime(freq, Tone.now());
}
}
}
function additiveKeyboardHandler(key, freq) {
if (!pressedKeys.includes(key)) {
pressedKeys.push(key);
if (pressedKeys.length === 1) {
filterEnvelope.triggerAttack();
}
additiveSynth.triggerAttack(freq, Tone.now());
}
}
function fmKeyboardKeyup(key, freq) {
if (pressedKeys.includes(key)) {
const index = pressedKeys.indexOf(key);
pressedKeys.splice(index, 1);
if (pressedKeys.length === 0) {
fmSynth.triggerRelease();
filterEnvelope.triggerRelease();
} else {
const firstKey = pressedKeys[0];
const firstNote = keyMap[firstKey];
const firstFreq = Tone.Frequency(firstNote).toFrequency();
if (glideTime > 0) {
fmSynth.frequency.rampTo(firstFreq, glideTime);
} else {
fmSynth.frequency.setValueAtTime(firstFreq, Tone.now());
}
}
}
}
function additiveKeyboardKeyup(key, freq) {
if (pressedKeys.includes(key)) {
const index = pressedKeys.indexOf(key);
pressedKeys.splice(index, 1);
additiveSynth.triggerRelease(freq, Tone.now());
if (pressedKeys.length === 0) {
filterEnvelope.triggerRelease();
}
}
}
function onMIDIMessage(message) {
const [command, note, velocity] = message.data;
if (currentSynth === fmSynth) {
fmMidiHandler(command, note, velocity);
} else {
additiveMidiHandler(command, note, velocity);
}
}
// Full chromatic keyboard mapping
const keyMap = {
'z': 'C2', 's': 'C#2', 'x': 'D2', 'd': 'D#2', 'c': 'E2', 'v': 'F2', 'g': 'F#2', 'b': 'G2', 'h': 'G#2', 'n': 'A2', 'j': 'A#2', 'm': 'B2',
'q': 'C3', '2': 'C#3', 'w': 'D3', '3': 'D#3', 'e': 'E3', 'r': 'F3', '5': 'F#3', 't': 'G3', '6': 'G#3', 'y': 'A3', '7': 'A#3', 'u': 'B3',
'i': 'C4', '9': 'C#4', 'o': 'D4', '0': 'D#4', 'p': 'E4', '[': 'F4', '=': 'F#4', ']': 'G4'
};
const pressedKeys = [];
// Oscilloscope
const canvas = document.querySelector('.oscilloscope');
const ctx = canvas.getContext('2d');
function resizeCanvas() {
canvas.width = canvas.offsetWidth;
canvas.height = canvas.offsetHeight;
}
resizeCanvas();
window.addEventListener('resize', resizeCanvas);
function drawOscilloscope() {
requestAnimationFrame(drawOscilloscope);
const values = analyzer.getValue();
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.beginPath();
ctx.strokeStyle = '#39ff14';
ctx.lineWidth = 2;
for(let i = 0; i < values.length; i++) {
const x = (i / values.length) * canvas.width;
const y = ((values[i] + 1) / 2) * canvas.height;
if(i === 0) {
ctx.moveTo(x, y);
} else {
ctx.lineTo(x, y);
}
}
ctx.stroke();
ctx.strokeStyle = '#39ff1440';
ctx.lineWidth = 1;
ctx.stroke();
}
drawOscilloscope();
// ===========================================
// SYNTH CONTROLS
// ===========================================
let currentEnvelopeMode = 'amp'; // 'amp', 'filter', or 'modulation'
let isLinked = false; // Whether envelopes are linked to synth
// ADSR Canvas
const adsrCanvas = document.querySelector('.adsr-canvas');
const adsrCtx = adsrCanvas.getContext('2d');
function drawADSR() {
adsrCtx.clearRect(0, 0, adsrCanvas.width, adsrCanvas.height);
adsrCtx.strokeStyle = '#39ff14';
adsrCtx.lineWidth = 2;
adsrCtx.beginPath();
const attack = parseFloat(document.getElementById('attack').value);
const decay = parseFloat(document.getElementById('decay').value);
const sustain = parseFloat(document.getElementById('sustain').value);
const release = parseFloat(document.getElementById('release').value);
const totalTime = attack + decay + 1 + release; // 1 second sustain
const width = adsrCanvas.width;
const height = adsrCanvas.height;
// Attack
const attackX = (attack / totalTime) * width;
adsrCtx.moveTo(0, height);
adsrCtx.lineTo(attackX, 0);
// Decay
const decayX = ((attack + decay) / totalTime) * width;
const sustainY = height - (sustain * height);
adsrCtx.lineTo(decayX, sustainY);
// Sustain
const sustainEndX = ((attack + decay + 1) / totalTime) * width;
adsrCtx.lineTo(sustainEndX, sustainY);
// Release
adsrCtx.lineTo(width, height);
adsrCtx.stroke();
}
// ADSR Tabs
document.querySelectorAll('.adsr-tab').forEach(tab => {
tab.addEventListener('click', () => {
document.querySelectorAll('.adsr-tab').forEach(t => t.classList.remove('active'));
tab.classList.add('active');
currentEnvelopeMode = tab.dataset.envelope;
// Update slider values to current envelope
updateADSRSliders();
});
});
// Link Button
document.getElementById('linkBtn').addEventListener('click', () => {
isLinked = !isLinked;
const linkBtn = document.getElementById('linkBtn');
if (isLinked) {
linkBtn.classList.add('active');
// When linking, sync all envelopes to synth values
syncEnvelopesToSynth();
} else {
linkBtn.classList.remove('active');
// When unlinking, allow independent control
updateADSRSliders();
}
});
function syncEnvelopesToSynth() {
// Get current synth envelope values
const attackVal = fmSynth.envelope.attack;
const decayVal = fmSynth.envelope.decay;
const sustainVal = fmSynth.envelope.sustain;
const releaseVal = fmSynth.envelope.release;
// Sync all envelopes to synth values
filterEnvelope.attack = attackVal;
filterEnvelope.decay = decayVal;
filterEnvelope.sustain = sustainVal;
filterEnvelope.release = releaseVal;
// Update additive synth envelopes too
additiveSynth.set({
voice0: { envelope: { attack: attackVal, decay: decayVal, sustain: sustainVal, release: releaseVal } },
voice1: { envelope: { attack: attackVal, decay: decayVal, sustain: sustainVal, release: releaseVal } }
});
}
function updateADSRSliders() {
let envelope;
if (currentEnvelopeMode === 'amp') {
envelope = fmSynth.envelope; // Use FM synth envelope as reference
} else if (currentEnvelopeMode === 'filter') {
envelope = filterEnvelope;
} else if (currentEnvelopeMode === 'modulation') {
envelope = fmSynth.modulationEnvelope;
}
// Disable sliders if linked and not in amp mode, or if modulation mode and not FM synth
const sliders = document.querySelectorAll('.adsr-slider');
const isDisabled = (isLinked && currentEnvelopeMode !== 'amp') || (currentEnvelopeMode === 'modulation' && currentSynth !== fmSynth);
sliders.forEach(slider => {
slider.disabled = isDisabled;
if (isDisabled) {
slider.style.opacity = '0.5';
slider.style.cursor = 'not-allowed';
} else {
slider.style.opacity = '1';
slider.style.cursor = 'pointer';
}
});
document.getElementById('attack').value = envelope.attack;
document.getElementById('decay').value = envelope.decay;
document.getElementById('sustain').value = envelope.sustain;
document.getElementById('release').value = envelope.release;
drawADSR();
}
// ADSR Sliders
document.querySelectorAll('.adsr-slider').forEach(slider => {
slider.addEventListener('input', () => {
if (currentEnvelopeMode === 'amp') {
// Set both synth envelopes
const attackVal = parseFloat(document.getElementById('attack').value);
const decayVal = parseFloat(document.getElementById('decay').value);
const sustainVal = parseFloat(document.getElementById('sustain').value);
const releaseVal = parseFloat(document.getElementById('release').value);
fmSynth.envelope.attack = attackVal;
fmSynth.envelope.decay = decayVal;
fmSynth.envelope.sustain = sustainVal;
fmSynth.envelope.release = releaseVal;
additiveSynth.set({
voice0: { envelope: { attack: attackVal, decay: decayVal, sustain: sustainVal, release: releaseVal } },
voice1: { envelope: { attack: attackVal, decay: decayVal, sustain: sustainVal, release: releaseVal } }
});
// If linked, sync all envelopes to these values
if (isLinked) {
filterEnvelope.attack = attackVal;
filterEnvelope.decay = decayVal;
filterEnvelope.sustain = sustainVal;
filterEnvelope.release = releaseVal;
}
} else if (currentEnvelopeMode === 'filter') {
if (!isLinked) {
filterEnvelope.attack = parseFloat(document.getElementById('attack').value);
filterEnvelope.decay = parseFloat(document.getElementById('decay').value);
filterEnvelope.sustain = parseFloat(document.getElementById('sustain').value);
filterEnvelope.release = parseFloat(document.getElementById('release').value);
}
} else if (currentEnvelopeMode === 'modulation') {
// Set modulation envelope for FM synth
fmSynth.modulationEnvelope.attack = parseFloat(document.getElementById('attack').value);
fmSynth.modulationEnvelope.decay = parseFloat(document.getElementById('decay').value);
fmSynth.modulationEnvelope.sustain = parseFloat(document.getElementById('sustain').value);
fmSynth.modulationEnvelope.release = parseFloat(document.getElementById('release').value);
}
drawADSR();
});
});
// Filter Type Selector (for filter envelope tab)
document.getElementById('filterType').addEventListener('change', (e) => {
filter.type = e.target.value;
});
// Filter Controls
document.getElementById('filterCutoff').addEventListener('input', (e) => {
const sliderValue = parseFloat(e.target.value);
let frequency;
if (sliderValue <= 10000) {
// First half: Linear mapping from 20Hz to 1000Hz
frequency = 20 + (sliderValue / 10000) * (1000 - 20);
} else {
// Second half: Exponential mapping from 1000Hz to 20000Hz
const normalizedValue = (sliderValue - 10000) / 10000; // 0 to 1
frequency = 1000 * Math.pow(20000 / 1000, normalizedValue);
}
filterEnvelope.baseFrequency = frequency;
document.getElementById('filterCutoffValue').textContent = Math.round(frequency) + 'Hz';
// Recalculate octaves based on current envelope amount
const amount = parseInt(document.getElementById('filterEnvelopeAmount').value);
const targetMax = 20000;
const octaves = (amount / 100) * Math.log2(targetMax / frequency);
filterEnvelope.octaves = octaves;
});
document.getElementById('filterResonance').addEventListener('input', (e) => {
const value = parseFloat(e.target.value);
filter.Q.value = value;
document.getElementById('filterResonanceValue').textContent = value.toFixed(1);
});
document.getElementById('filterEnvelopeAmount').addEventListener('input', (e) => {
const amount = parseInt(e.target.value);
document.getElementById('filterEnvelopeAmountValue').textContent = amount + '%';
// Calculate octaves based on envelope amount
const baseFreq = filterEnvelope.baseFrequency;
const targetMax = 20000;
const octaves = (amount / 100) * Math.log2(targetMax / baseFreq);
filterEnvelope.octaves = octaves;
});
// Initialize ADSR display
drawADSR();
// ===========================================
// MODULATION CONTROLS
// ===========================================
// Parameter options for different synth types
const parameterOptions = {
fm: {
modulationIndex: { name: 'Modulation Index', min: 0, max: 100 },
harmonicity: { name: 'Harmonicity', min: 0, max: 10 },
oscillatorDetune: { name: 'Oscillator Detune', min: -100, max: 100 },
modulationDetune: { name: 'Modulation Detune', min: -100, max: 100 }
},
additive: {
harmonicity: { name: 'Harmonicity', min: 0.5, max: 3.5 },
vibratoAmount: { name: 'Vibrato Amount', min: 0, max: 1 },
vibratoRate: { name: 'Vibrato Rate', min: 0, max: 10 },
detune: { name: 'Detune', min: -100, max: 100 }
}
};
// Parameter accessors for dynamic property access
const parameterAccessors = {
fm: {
modulationIndex: {
get: () => fmSynth.modulationIndex.value,
set: (value) => fmSynth.modulationIndex.value = value
},
harmonicity: {
get: () => fmSynth.harmonicity.value,
set: (value) => fmSynth.harmonicity.value = value
},
oscillatorDetune: {
get: () => fmSynth.oscillator.detune.value,
set: (value) => fmSynth.oscillator.detune.value = value
},
modulationDetune: {
get: () => fmSynth.modulation.detune.value,
set: (value) => fmSynth.modulation.detune.value = value
}
},
additive: {
harmonicity: {
get: () => additiveSynth.get().harmonicity || 1,
set: (value) => additiveSynth.set({ harmonicity: value })
},
vibratoAmount: {
get: () => additiveSynth.get().vibratoAmount || 0,
set: (value) => additiveSynth.set({ vibratoAmount: value })
},
vibratoRate: {
get: () => additiveSynth.get().vibratoRate || 5,
set: (value) => additiveSynth.set({ vibratoRate: value })
},
detune: {
get: () => additiveSynth.get().detune || 0,
set: (value) => additiveSynth.set({ detune: value })
}
}
};
// Current modulation parameters
let currentXParam = 'modulationIndex';
let currentYParam = 'harmonicity';
// Function to synchronize cursor position and parameter values
function syncCursorAndParameters(updateCursor = false) {
const cursor = document.querySelector('.xy-cursor');
const xyPad = document.querySelector('.xy-pad');
const rect = xyPad.getBoundingClientRect();
if (updateCursor) {
// Update cursor position based on current parameter values
const synthType = currentSynth === fmSynth ? 'fm' : 'additive';
const xParam = parameterOptions[synthType][currentXParam];
const yParam = parameterOptions[synthType][currentYParam];
const xValue = getCurrentParameterValue(currentXParam);
const yValue = getCurrentParameterValue(currentYParam);
// Normalize to 0-1 range
const xNormalized = (xValue - xParam.min) / (xParam.max - xParam.min);
const yNormalized = (yValue - yParam.min) / (yParam.max - yParam.min);
cursor.style.left = `${xNormalized * rect.width}px`;
cursor.style.top = `${yNormalized * rect.height}px`;
} else {
// Update parameters based on current cursor position
const cursorLeft = parseFloat(cursor.style.left) || 0;
const cursorTop = parseFloat(cursor.style.top) || 0;
const x = Math.max(0, Math.min(1, cursorLeft / rect.width));
const y = Math.max(0, Math.min(1, cursorTop / rect.height));
// Update synth parameters based on selected modulation parameters
const synthType = currentSynth === fmSynth ? 'fm' : 'additive';
const xParam = parameterOptions[synthType][currentXParam];
const yParam = parameterOptions[synthType][currentYParam];
const xValue = xParam.min + x * (xParam.max - xParam.min);
const yValue = yParam.min + y * (yParam.max - yParam.min);
setParameterValue(currentXParam, xValue);
setParameterValue(currentYParam, yValue);
// Update slider values
document.getElementById('xModulation').value = xValue;
document.getElementById('xModulationValue').textContent = xValue.toFixed(1);
document.getElementById('yModulation').value = yValue;
document.getElementById('yModulationValue').textContent = yValue.toFixed(1);
}
}
// Populate parameter dropdowns based on current synth
function populateParameterDropdowns(updateSliders = true) {
const synthType = currentSynth === fmSynth ? 'fm' : 'additive';
const options = parameterOptions[synthType];
// Populate X parameter dropdown
const xSelect = document.getElementById('xParamSelect');
xSelect.innerHTML = '';
Object.keys(options).forEach(key => {
const option = document.createElement('option');
option.value = key;
option.textContent = options[key].name;
if (key === currentXParam) option.selected = true;
xSelect.appendChild(option);
});
// Populate Y parameter dropdown
const ySelect = document.getElementById('yParamSelect');
ySelect.innerHTML = '';
Object.keys(options).forEach(key => {
const option = document.createElement('option');
option.value = key;
option.textContent = options[key].name;
if (key === currentYParam) option.selected = true;
ySelect.appendChild(option);
});
// Update slider ranges and values (skip during synth switching to avoid redundancy)
if (updateSliders) {
updateModulationSliders();
}
}
// Update modulation sliders based on selected parameters
function updateModulationSliders() {
const synthType = currentSynth === fmSynth ? 'fm' : 'additive';
const xParam = parameterOptions[synthType][currentXParam];
const yParam = parameterOptions[synthType][currentYParam];
// Update X slider
const xSlider = document.getElementById('xModulation');
xSlider.min = xParam.min;
xSlider.max = xParam.max;
xSlider.value = getCurrentParameterValue(currentXParam);
document.getElementById('xModulationValue').textContent = xSlider.value;
// Update Y slider
const ySlider = document.getElementById('yModulation');
ySlider.min = yParam.min;
ySlider.max = yParam.max;
ySlider.value = getCurrentParameterValue(currentYParam);
document.getElementById('yModulationValue').textContent = ySlider.value;
}
// Get current value of a parameter
function getCurrentParameterValue(param) {
const synthType = currentSynth === fmSynth ? 'fm' : 'additive';
const accessor = parameterAccessors[synthType][param];
return accessor ? accessor.get() : 0;
}
// Set parameter value
function setParameterValue(param, value) {
const synthType = currentSynth === fmSynth ? 'fm' : 'additive';
const accessor = parameterAccessors[synthType][param];
if (accessor) accessor.set(value);
}
// ===========================================
// UI CONTROLS
// ===========================================
// XY Pad
const xyPad = document.querySelector('.xy-pad');
const cursor = document.querySelector('.xy-cursor');
let isDrawing = false;
function updateXY(e) {
const rect = xyPad.getBoundingClientRect();
const x = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
const y = Math.max(0, Math.min(1, (e.clientY - rect.top) / rect.height));
cursor.style.left = `${x * rect.width}px`;
cursor.style.top = `${y * rect.height}px`;
// Update synth parameters based on selected modulation parameters
const synthType = currentSynth === fmSynth ? 'fm' : 'additive';
const xParam = parameterOptions[synthType][currentXParam];
const yParam = parameterOptions[synthType][currentYParam];
const xValue = xParam.min + x * (xParam.max - xParam.min);
const yValue = yParam.min + y * (yParam.max - yParam.min);
setParameterValue(currentXParam, xValue);
setParameterValue(currentYParam, yValue);
// Update slider values
document.getElementById('xModulation').value = xValue;
document.getElementById('xModulationValue').textContent = xValue.toFixed(1);
document.getElementById('yModulation').value = yValue;
document.getElementById('yModulationValue').textContent = yValue.toFixed(1);
}
xyPad.addEventListener('mousedown', (e) => {
isDrawing = true;
updateXY(e);
});
window.addEventListener('mousemove', (e) => {
if (isDrawing) updateXY(e);
});
window.addEventListener('mouseup', () => {
isDrawing = false;
});
// Glide Time Control
document.getElementById('glideTime').addEventListener('input', (e) => {
glideTime = parseFloat(e.target.value);
document.getElementById('glideTimeValue').textContent = glideTime.toFixed(2) + 's';
});
// Function to switch between synth types
function switchSynth(soundType) {
// Disconnect current synth
currentSynth.disconnect();
// Set new synth
currentSynth = getSynthByType(soundType);
currentSynth.chain(filter, ...effectsChain, analyzer, Tone.Destination);
// Maintain compressor bypass state
if (compressorBypassed) {
compressor.disconnect(analyzer);
reverb.connect(analyzer);
}
// Update waveform for new synth
updateWaveform(currentWaveform);
// Handle modulation tab visibility and envelope mode
const modulationTab = document.querySelector('.adsr-tab[data-envelope="modulation"]');
if (soundType === 'fm') {
modulationTab.classList.remove('hidden');
} else if (soundType === 'additive') {
modulationTab.classList.add('hidden');
// If currently in modulation mode, switch to amp mode
if (currentEnvelopeMode === 'modulation') {
currentEnvelopeMode = 'amp';
document.querySelectorAll('.adsr-tab').forEach(t => t.classList.remove('active'));
document.querySelector('.adsr-tab[data-envelope="amp"]').classList.add('active');
}
}
// Update ADSR sliders if in amp mode
if (currentEnvelopeMode === 'amp') {
updateADSRSliders();
}
// Set correct default parameters for the new synth
if (soundType === 'fm') {
currentXParam = 'modulationIndex';
currentYParam = 'harmonicity';
} else if (soundType === 'additive') {
currentXParam = 'vibratoAmount';
currentYParam = 'vibratoRate';
}
// Update parameter dropdowns for new synth (skip redundant slider updates)
populateParameterDropdowns(false);
// Update parameters based on current cursor position
syncCursorAndParameters();
// Handle polyphony for additive synth
if (soundType === 'additive') {
currentSynth.maxPolyphony = 16;
}
}
// Sound Generation Selection
document.querySelectorAll('.sound-btn').forEach(btn => {
btn.addEventListener('click', () => {
document.querySelectorAll('.sound-btn').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
const soundType = btn.dataset.sound;
switchSynth(soundType);
});
});
// Waveform Selection
document.querySelectorAll('.wave-btn').forEach(btn => {
btn.addEventListener('click', () => {
document.querySelectorAll('.wave-btn').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
currentWaveform = btn.dataset.wave;
updateWaveform(currentWaveform);
});
});
function updateWaveform(waveform) {
if (currentSynth === fmSynth) {
// FM synth has modulation oscillator
fmSynth.oscillator.type = waveform;
fmSynth.modulation.type = waveform;
} else if (currentSynth === additiveSynth) {
// DuoSynth has two voices, update both oscillators
additiveSynth.set({
voice0: { oscillator: { type: waveform } },
voice1: { oscillator: { type: waveform } }
});
}
}
function getSynthByType(type) {
switch(type) {
case 'fm': return fmSynth;
case 'additive': return additiveSynth;
default: return fmSynth;
}
}
// Effect Mix Controls
document.getElementById('reverbMix').addEventListener('input', (e) => {
const value = parseInt(e.target.value) / 100;
document.getElementById('reverbMixValue').textContent = e.target.value + '%';
reverb.wet.value = value;
});
document.getElementById('compressorRatio').addEventListener('input', (e) => {
const value = parseFloat(e.target.value);
document.getElementById('compressorRatioValue').textContent = value.toFixed(1) + ':1';
compressor.ratio = value;
});
document.getElementById('compressorThreshold').addEventListener('input', (e) => {
const value = parseInt(e.target.value);
document.getElementById('compressorThresholdValue').textContent = value + 'dB';
compressor.threshold = value;
});
document.getElementById('compressorAttack').addEventListener('input', (e) => {
const value = parseFloat(e.target.value);
document.getElementById('compressorAttackValue').textContent = value.toFixed(2) + 's';
compressor.attack = value;
});
document.getElementById('compressorRelease').addEventListener('input', (e) => {
const value = parseFloat(e.target.value);
document.getElementById('compressorReleaseValue').textContent = value.toFixed(2) + 's';
compressor.release = value;
});
// Function to switch distortion preset
function switchDistortionPreset(presetIndex) {
const preset = distortionPresets[presetIndex];
currentDistortionPreset = presetIndex;
// Update UI
document.getElementById('distortionHeader').textContent = 'DISTORTION: ' + preset.name;
// Configure first Chebyshev
if (preset.chebyshev1) {
chebyshev1.order = preset.chebyshev1.order;
chebyshev1.wet.value = preset.chebyshev1.wet;
} else {
chebyshev1.wet.value = 0;
}
// Configure second Chebyshev
if (preset.chebyshev2) {
chebyshev2.order = preset.chebyshev2.order;
chebyshev2.wet.value = preset.chebyshev2.wet;
} else {
chebyshev2.wet.value = 0;
}
// Configure gain
distortionGain.gain.value = preset.gain;
// Configure feedback
if (preset.feedback) {
distortionFeedback.wet.value = 0.1; // Subtle feedback
} else {
distortionFeedback.wet.value = 0;
}
}
document.getElementById('distortionPreset').addEventListener('input', (e) => {
const presetIndex = parseInt(e.target.value) - 1; // Convert 1-8 to 0-7
switchDistortionPreset(presetIndex);
});
document.getElementById('distortionMix').addEventListener('input', (e) => {
const mixValue = parseInt(e.target.value) / 100;
document.getElementById('distortionMixValue').textContent = e.target.value + '%';
// Apply mix to the distortion chain
const preset = distortionPresets[currentDistortionPreset];
// Scale the wet values based on mix
if (preset.chebyshev1) {
chebyshev1.wet.value = preset.chebyshev1.wet * mixValue;
}
if (preset.chebyshev2) {
chebyshev2.wet.value = preset.chebyshev2.wet * mixValue;
}
// Activate feedback at high mix levels (70%+)
if (preset.feedback && mixValue > 0.7) {
distortionFeedback.wet.value = 0.1 * (mixValue - 0.7) / 0.3; // Fade in feedback
} else if (preset.feedback) {
distortionFeedback.wet.value = 0;
}
});
document.getElementById('chorusMix').addEventListener('input', (e) => {
const value = parseInt(e.target.value) / 100;
document.getElementById('chorusMixValue').textContent = e.target.value + '%';
chorus.wet.value = value;
});
document.getElementById('reverbDecay').addEventListener('input', (e) => {
const value = parseFloat(e.target.value);
document.getElementById('reverbDecayValue').textContent = value.toFixed(1) + 's';
reverb.decay = value;
});
document.getElementById('reverbPreDelay').addEventListener('input', (e) => {
const value = parseFloat(e.target.value);
document.getElementById('reverbPreDelayValue').textContent = value.toFixed(2) + 's';
reverb.preDelay = value;
});
document.getElementById('chorusDepth').addEventListener('input', (e) => {
const value = parseFloat(e.target.value);
document.getElementById('chorusDepthValue').textContent = value.toFixed(2);
chorus.depth.value = value;
});
document.getElementById('chorusSpread').addEventListener('input', (e) => {
const value = parseInt(e.target.value);
document.getElementById('chorusSpreadValue').textContent = value + '°';
chorus.spread.value = value;
});
document.getElementById('chorusFrequency').addEventListener('input', (e) => {
const value = parseFloat(e.target.value);
document.getElementById('chorusFrequencyValue').textContent = value.toFixed(1) + 'Hz';
chorus.frequency.value = value;
});
// Effect slider IDs for disabling when toggled off
const effectSliders = {
distortion: ['distortionPreset', 'distortionMix'],
chorus: ['chorusMix', 'chorusDepth', 'chorusSpread', 'chorusFrequency'],
reverb: ['reverbMix', 'reverbDecay', 'reverbPreDelay']
};
// Effect Toggles
document.querySelectorAll('.toggle').forEach(btn => {
if (btn.dataset.effect) {
btn.addEventListener('click', () => {
btn.classList.toggle('active');
const effect = btn.dataset.effect;
const isActive = btn.classList.contains('active');
btn.textContent = isActive ? 'ON' : 'OFF';
switch(effect) {
case 'reverb':
reverb.wet.value = isActive ? parseInt(document.getElementById('reverbMix').value) / 100 : 0;
break;
case 'compressor':
if (isActive) {
if (compressorBypassed) {
// Re-enable compressor: connect through compressor
reverb.disconnect(analyzer);
compressor.connect(analyzer);
compressorBypassed = false;
}
} else {
if (!compressorBypassed) {
// Bypass: disconnect compressor, connect reverb directly
compressor.disconnect(analyzer);
reverb.connect(analyzer);
compressorBypassed = true;
}
}
break;
case 'distortion':
// For distortion, we need to handle the mix differently since it's now a complex chain
const mixValue = isActive ? parseInt(document.getElementById('distortionMix').value) / 100 : 0;
const preset = distortionPresets[currentDistortionPreset];
if (preset.chebyshev1) {
chebyshev1.wet.value = preset.chebyshev1.wet * mixValue;
}
if (preset.chebyshev2) {
chebyshev2.wet.value = preset.chebyshev2.wet * mixValue;
}
// Handle feedback for presets that support it
if (preset.feedback && mixValue > 0.7) {
distortionFeedback.wet.value = 0.1 * (mixValue - 0.7) / 0.3;
} else if (preset.feedback) {
distortionFeedback.wet.value = 0;
}
break;
case 'chorus':
chorus.wet.value = isActive ? parseInt(document.getElementById('chorusMix').value) / 100 : 0;
break;
}
// Disable/enable sliders for effects with MIX sliders
const sliders = effectSliders[effect];
if (sliders) {
sliders.forEach(id => {
const slider = document.getElementById(id);
slider.disabled = !isActive;
});
}
// Add/remove visual indicator for off state
const effectGroup = btn.closest('.effect-group');
if (isActive) {
effectGroup.classList.remove('effect-off');
} else {
effectGroup.classList.add('effect-off');
}
});
}
});
// ===========================================
// RECORDING FUNCTIONALITY
// ===========================================
const recordBtn = document.getElementById('recordBtn');
const waveformCanvas = document.getElementById('waveformCanvas');
const waveformCtx = waveformCanvas.getContext('2d');
const waveformContainer = document.querySelector('.waveform-container');
const selectionDiv = document.getElementById('selection');
const sliceBtn = document.getElementById('sliceBtn');
const playhead = document.getElementById('playhead');
const playheadHandle = document.getElementById('playheadHandle');
const playSampleBtn = document.getElementById('playSample');
// Playback state
let isPlaying = false;
let audioElement = null;
let isDraggingPlayhead = false;
// Selection state
let isSelecting = false;
let selectionStart = 0;
let selectionEnd = 0;
recordBtn.addEventListener('click', async () => {
try {
if (!isRecording) {
// Start recording synth output
await toneRecorder.start();
isRecording = true;
recordBtn.classList.add('recording');
} else {
// Stop recording and process the result
const recording = await toneRecorder.stop();
isRecording = false;
recordBtn.classList.remove('recording');
// Clean up previous URL to prevent memory leaks
if (sampleBuffer) {
URL.revokeObjectURL(sampleBuffer);
}
// Convert recording to blob and create URL
const blob = new Blob([recording], { type: 'audio/wav' });
const url = URL.createObjectURL(blob);
sampleBuffer = url;
// Generate waveform data from the recording
generateWaveform(blob);
}
} catch (error) {
console.error('Recording error:', error);
isRecording = false;
recordBtn.classList.remove('recording');
}
});
function generateWaveform(blob) {
try {
const audioContext = new AudioContext();
blob.arrayBuffer().then(arrayBuffer => {
audioContext.decodeAudioData(arrayBuffer).then(audioBuffer => {
const channelData = audioBuffer.getChannelData(0);
waveformData = channelData;
// Draw waveform
drawWaveform(channelData);
}).catch(error => {
console.error('Error decoding audio data:', error);
});
}).catch(error => {
console.error('Error reading blob array buffer:', error);
});
} catch (error) {
console.error('Error creating audio context:', error);
}
}
function drawWaveform(data) {
waveformCanvas.width = waveformCanvas.offsetWidth;
waveformCanvas.height = waveformCanvas.offsetHeight;
waveformCtx.clearRect(0, 0, waveformCanvas.width, waveformCanvas.height);
waveformCtx.strokeStyle = '#39ff14';
waveformCtx.lineWidth = 1;
waveformCtx.beginPath();
const step = Math.ceil(data.length / waveformCanvas.width);
for (let i = 0; i < waveformCanvas.width; i++) {
const sampleIndex = i * step;
const amplitude = data[sampleIndex] || 0;
const y = (amplitude + 1) * waveformCanvas.height / 2;
if (i === 0) {
waveformCtx.moveTo(i, y);
} else {
waveformCtx.lineTo(i, y);
}
}
waveformCtx.stroke();
}
// Sample controls
playSampleBtn.addEventListener('click', () => {
if (!sampleBuffer) return;
if (isPlaying) {
// Pause playback
if (audioElement) {
audioElement.pause();
audioElement = null;
}
isPlaying = false;
updatePlayButtonIcon(false);
} else {
// Start playback
audioElement = new Audio(sampleBuffer);
audioElement.currentTime = 0; // Reset to start
// Update playhead position during playback
audioElement.addEventListener('timeupdate', () => {
if (audioElement && audioElement.duration) {
const progress = audioElement.currentTime / audioElement.duration;
playhead.style.left = `${progress * 100}%`;
playheadHandle.style.left = `${progress * 100}%`;
}
});
// Handle playback end
audioElement.addEventListener('ended', () => {
isPlaying = false;
audioElement = null;
playhead.style.left = '0%';
playheadHandle.style.left = '0%';
updatePlayButtonIcon(false);
});
audioElement.play();
isPlaying = true;
updatePlayButtonIcon(true);
}
});
function updatePlayButtonIcon(playing) {
const svg = playSampleBtn.querySelector('svg');
if (playing) {
// Change to pause icon (two bars)
svg.innerHTML = `
<rect x="6" y="4" width="3" height="16"/>
<rect x="13" y="4" width="3" height="16"/>
`;
} else {
// Change to play icon (triangle)
svg.innerHTML = '<polygon points="8,5 19,12 8,19"/>';
}
}
document.getElementById('exportWav').addEventListener('click', () => {
if (sampleBuffer) {
const a = document.createElement('a');
a.href = sampleBuffer;
a.download = 'ms1x_sample.wav';
a.click();
}
});
// Playhead drag functionality
playheadHandle.addEventListener('mousedown', (e) => {
e.stopPropagation(); // Prevent triggering selection
isDraggingPlayhead = true;
});
window.addEventListener('mousemove', (e) => {
if (isDraggingPlayhead && sampleBuffer) {
const rect = waveformContainer.getBoundingClientRect();
const x = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
const positionPercent = x * 100;
playhead.style.left = `${positionPercent}%`;
playheadHandle.style.left = `${positionPercent}%`;
// Seek audio if playing
if (audioElement && audioElement.duration) {
audioElement.currentTime = x * audioElement.duration;
}
} else if (!isSelecting || !waveformData.length) return;
const rect = waveformContainer.getBoundingClientRect();
selectionEnd = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
updateSelection();
});
window.addEventListener('mouseup', () => {
isDraggingPlayhead = false;
isSelecting = false;
});
// Waveform selection functionality
waveformContainer.addEventListener('mousedown', (e) => {
if (!waveformData.length) return;
isSelecting = true;
const rect = waveformContainer.getBoundingClientRect();
selectionStart = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
selectionEnd = selectionStart;
updateSelection();
});
function updateSelection() {
if (!waveformData.length) return;
const startPercent = Math.min(selectionStart, selectionEnd) * 100;
const endPercent = Math.max(selectionStart, selectionEnd) * 100;
const widthPercent = endPercent - startPercent;
selectionDiv.style.display = widthPercent > 0 ? 'block' : 'none';
selectionDiv.style.left = `${startPercent}%`;
selectionDiv.style.width = `${widthPercent}%`;
}
// Slice functionality
sliceBtn.addEventListener('click', () => {
if (!waveformData.length || selectionDiv.style.display === 'none') return;
const startPercent = parseFloat(selectionDiv.style.left) / 100;
const widthPercent = parseFloat(selectionDiv.style.width) / 100;
const endPercent = startPercent + widthPercent;
const startIndex = Math.floor(startPercent * waveformData.length);
const endIndex = Math.floor(endPercent * waveformData.length);
// Extract selected portion of audio data
const slicedData = waveformData.slice(startIndex, endIndex);
// Update waveform data and redraw
waveformData = slicedData;
drawWaveform(slicedData);
// Create new audio buffer from sliced data
const audioContext = new AudioContext();
const audioBuffer = audioContext.createBuffer(1, slicedData.length, audioContext.sampleRate);
audioBuffer.copyFromChannel(slicedData, 0);
// Convert back to WAV blob
audioBufferToWav(audioBuffer).then(blob => {
// Clean up previous URL to prevent memory leaks
if (sampleBuffer) {
URL.revokeObjectURL(sampleBuffer);
}
// Replace the original audio with the trimmed version
const url = URL.createObjectURL(blob);
sampleBuffer = url;
// Stop any current playback and reset playhead
if (isPlaying && audioElement) {
audioElement.pause();
audioElement = null;
isPlaying = false;
updatePlayButtonIcon(false);
}
// Reset playhead position
playhead.style.left = '0%';
playheadHandle.style.left = '0%';
// Hide selection after slicing
selectionDiv.style.display = 'none';
});
});
function audioBufferToWav(buffer) {
return new Promise((resolve, reject) => {
try {
const length = buffer.length;
const sampleRate = buffer.sampleRate;
const numChannels = buffer.numberOfChannels;
const arrayBuffer = new ArrayBuffer(44 + length * numChannels * 2);
const view = new DataView(arrayBuffer);
// WAV header
const writeString = (offset, string) => {
for (let i = 0; i < string.length; i++) {
view.setUint8(offset + i, string.charCodeAt(i));
}
};
writeString(0, 'RIFF');
view.setUint32(4, 36 + length * numChannels * 2, true);
writeString(8, 'WAVE');
writeString(12, 'fmt ');
view.setUint32(16, 16, true);
view.setUint16(20, 1, true);
view.setUint16(22, numChannels, true);
view.setUint32(24, sampleRate, true);
view.setUint32(28, sampleRate * numChannels * 2, true);
view.setUint16(32, numChannels * 2, true);
view.setUint16(34, 16, true);
writeString(36, 'data');
view.setUint32(40, length * numChannels * 2, true);
// Audio data
let offset = 44;
for (let i = 0; i < length; i++) {
for (let channel = 0; channel < numChannels; channel++) {
const sample = Math.max(-1, Math.min(1, buffer.getChannelData(channel)[i]));
view.setInt16(offset, sample * 0x7FFF, true);
offset += 2;
}
}
resolve(new Blob([arrayBuffer], { type: 'audio/wav' }));
} catch (error) {
reject(error);
}
});
}
// ===========================================
// MODULATION EVENT LISTENERS
// ===========================================
// Parameter dropdown change events
document.getElementById('xParamSelect').addEventListener('change', (e) => {
currentXParam = e.target.value;
updateModulationSliders();
syncCursorAndParameters(true);
});
document.getElementById('yParamSelect').addEventListener('change', (e) => {
currentYParam = e.target.value;
updateModulationSliders();
syncCursorAndParameters(true);
});
// Modulation slider events
document.getElementById('xModulation').addEventListener('input', (e) => {
const value = parseFloat(e.target.value);
setParameterValue(currentXParam, value);
document.getElementById('xModulationValue').textContent = value.toFixed(1);
syncCursorAndParameters(true);
});
document.getElementById('yModulation').addEventListener('input', (e) => {
const value = parseFloat(e.target.value);
setParameterValue(currentYParam, value);
document.getElementById('yModulationValue').textContent = value.toFixed(1);
syncCursorAndParameters(true);
});
// Initialize modulation controls
populateParameterDropdowns();
// ===========================================
// CLEANUP AND MEMORY MANAGEMENT
// ===========================================
// Clean up resources when page unloads
window.addEventListener('beforeunload', () => {
if (sampleBuffer) {
URL.revokeObjectURL(sampleBuffer);
}
// Stop any ongoing recording
if (isRecording) {
try {
toneRecorder.stop();
} catch (e) {
// Ignore errors during cleanup
}
}
});
// Keyboard Events
window.addEventListener('keydown', (e) => {
const key = e.key.toLowerCase();
if (keyMap[key]) {
const note = keyMap[key];
const freq = Tone.Frequency(note).toFrequency();
if (currentSynth === fmSynth) {
fmKeyboardHandler(key, freq);
} else {
additiveKeyboardHandler(key, freq);
}
}
});
window.addEventListener('keyup', (e) => {
const key = e.key.toLowerCase();
if (keyMap[key]) {
const note = keyMap[key];
const freq = Tone.Frequency(note).toFrequency();
if (currentSynth === fmSynth) {
fmKeyboardKeyup(key, freq);
} else {
additiveKeyboardKeyup(key, freq);
}
}
});
// Initialize effect sliders as disabled since effects start off
document.querySelectorAll('.toggle').forEach(btn => {
if (btn.dataset.effect && !btn.classList.contains('active')) {
const effect = btn.dataset.effect;
const sliders = effectSliders[effect];
if (sliders) {
sliders.forEach(id => {
const slider = document.getElementById(id);
slider.disabled = true;
});
}
// Add visual indicator for off state
const effectGroup = btn.closest('.effect-group');
effectGroup.classList.add('effect-off');
}
});
// Start audio context on first interaction
document.body.addEventListener('click', () => {
Tone.start();
}, { once: true });
</script>
</body>
</html>