Spaces:
Running
Running
| <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> | |