Spaces:
Paused
Paused
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <title>SillyLoops - Retro ARP Sampler</title> | |
| <style> | |
| * { | |
| margin: 0; | |
| padding: 0; | |
| box-sizing: border-box; | |
| } | |
| body { | |
| font-family: 'Roboto Mono', 'Courier New', monospace; | |
| background: linear-gradient(135deg, #1A1A2E 0%, #16213E 50%, #0F3460 100%); | |
| min-height: 100vh; | |
| color: white; | |
| overflow: hidden; | |
| } | |
| .container { | |
| max-width: 800px; | |
| margin: 0 auto; | |
| padding: 20px; | |
| height: 100vh; | |
| display: flex; | |
| flex-direction: column; | |
| } | |
| header { | |
| display: flex; | |
| justify-content: space-between; | |
| align-items: center; | |
| padding: 10px 0; | |
| } | |
| h1 { | |
| font-size: 28px; | |
| letter-spacing: 3px; | |
| background: linear-gradient(90deg, #9C27B0, #E91E63); | |
| -webkit-background-clip: text; | |
| -webkit-text-fill-color: transparent; | |
| } | |
| /* LCD Display */ | |
| .lcd-display { | |
| background: #0A1929; | |
| border: 3px solid #2A6F6F; | |
| border-radius: 12px; | |
| padding: 16px; | |
| margin: 10px 0; | |
| box-shadow: 0 0 20px rgba(42, 111, 111, 0.5); | |
| } | |
| .lcd-content { | |
| display: flex; | |
| align-items: center; | |
| gap: 16px; | |
| } | |
| .lcd-icon { | |
| font-size: 40px; | |
| color: #4ECDC4; | |
| } | |
| .lcd-info { | |
| flex: 1; | |
| } | |
| .lcd-row { | |
| display: flex; | |
| gap: 20px; | |
| margin-bottom: 8px; | |
| } | |
| .lcd-label { | |
| color: rgba(255,255,255,0.4); | |
| font-size: 10px; | |
| font-weight: bold; | |
| letter-spacing: 1px; | |
| } | |
| .lcd-value { | |
| color: #4ECDC4; | |
| font-size: 16px; | |
| font-weight: bold; | |
| } | |
| .lcd-value.active { | |
| color: #4CAF50; | |
| animation: blink 1s infinite; | |
| } | |
| @keyframes blink { | |
| 0%, 100% { opacity: 1; } | |
| 50% { opacity: 0.5; } | |
| } | |
| /* Bank Selector */ | |
| .bank-selector { | |
| display: flex; | |
| align-items: center; | |
| gap: 12px; | |
| padding: 10px 0; | |
| } | |
| .bank-label { | |
| color: rgba(255,255,255,0.7); | |
| font-size: 14px; | |
| font-weight: bold; | |
| } | |
| .bank-buttons { | |
| display: flex; | |
| flex: 1; | |
| gap: 8px; | |
| } | |
| .bank-btn { | |
| flex: 1; | |
| padding: 12px; | |
| background: rgba(255,255,255,0.1); | |
| border: 1px solid rgba(255,255,255,0.24); | |
| border-radius: 8px; | |
| color: rgba(255,255,255,0.7); | |
| font-size: 18px; | |
| font-weight: bold; | |
| cursor: pointer; | |
| transition: all 0.2s; | |
| } | |
| .bank-btn.active { | |
| background: #9C27B0; | |
| border-color: white; | |
| color: white; | |
| box-shadow: 0 0 20px rgba(156, 39, 176, 0.5); | |
| } | |
| .bank-btn:hover { | |
| background: rgba(255,255,255,0.2); | |
| } | |
| .nav-btn { | |
| width: 36px; | |
| height: 36px; | |
| border-radius: 50%; | |
| background: rgba(255,255,255,0.1); | |
| border: 1px solid rgba(255,255,255,0.24); | |
| color: white; | |
| font-size: 20px; | |
| cursor: pointer; | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| } | |
| .nav-btn:hover { | |
| background: rgba(255,255,255,0.2); | |
| } | |
| /* Pad Grid */ | |
| .pad-grid { | |
| flex: 1; | |
| background: rgba(0,0,0,0.26); | |
| border-radius: 16px; | |
| border: 1px solid rgba(255,255,255,0.1); | |
| padding: 12px; | |
| display: grid; | |
| grid-template-columns: repeat(4, 1fr); | |
| grid-template-rows: repeat(2, 1fr); | |
| gap: 12px; | |
| margin: 20px 0; | |
| } | |
| .drum-pad { | |
| background: linear-gradient(135deg, var(--pad-color), var(--pad-color-dark)); | |
| border-radius: 12px; | |
| border: 1px solid rgba(255,255,255,0.24); | |
| display: flex; | |
| flex-direction: column; | |
| align-items: center; | |
| justify-content: center; | |
| cursor: pointer; | |
| transition: all 0.1s; | |
| position: relative; | |
| user-select: none; | |
| } | |
| .drum-pad:active { | |
| transform: scale(0.95); | |
| box-shadow: 0 0 10px var(--pad-color); | |
| } | |
| .drum-pad.playing { | |
| box-shadow: 0 0 30px var(--pad-color); | |
| border-color: white; | |
| } | |
| .drum-pad.recording { | |
| animation: recordPulse 0.5s infinite; | |
| border-color: #f44336; | |
| } | |
| @keyframes recordPulse { | |
| 0%, 100% { box-shadow: 0 0 20px #f44336; } | |
| 50% { box-shadow: 0 0 40px #f44336; } | |
| } | |
| .drum-pad .number { | |
| font-size: 32px; | |
| font-weight: bold; | |
| } | |
| .drum-pad .name { | |
| font-size: 10px; | |
| color: rgba(255,255,255,0.7); | |
| text-align: center; | |
| margin-top: 4px; | |
| max-width: 80%; | |
| } | |
| .loop-indicator { | |
| position: absolute; | |
| top: 4px; | |
| right: 4px; | |
| width: 8px; | |
| height: 8px; | |
| background: white; | |
| border-radius: 50%; | |
| display: none; | |
| } | |
| .drum-pad.loop .loop-indicator { | |
| display: block; | |
| } | |
| .mic-indicator { | |
| position: absolute; | |
| top: 4px; | |
| left: 4px; | |
| font-size: 14px; | |
| display: none; | |
| } | |
| .drum-pad.has-mic .mic-indicator { | |
| display: block; | |
| } | |
| /* Control Panel */ | |
| .control-panel { | |
| background: rgba(0,0,0,0.38); | |
| border-radius: 16px; | |
| border: 1px solid rgba(255,255,255,0.1); | |
| padding: 16px; | |
| margin-bottom: 16px; | |
| } | |
| .control-row { | |
| display: flex; | |
| justify-content: space-around; | |
| align-items: center; | |
| margin-bottom: 12px; | |
| } | |
| .control-group { | |
| text-align: center; | |
| } | |
| .control-label { | |
| color: rgba(255,255,255,0.7); | |
| font-size: 12px; | |
| font-weight: bold; | |
| margin-bottom: 8px; | |
| } | |
| .bpm-control { | |
| display: flex; | |
| align-items: center; | |
| gap: 8px; | |
| } | |
| .bpm-btn { | |
| width: 36px; | |
| height: 36px; | |
| border-radius: 8px; | |
| background: rgba(255,255,255,0.1); | |
| border: 1px solid rgba(255,255,255,0.24); | |
| color: white; | |
| font-size: 20px; | |
| cursor: pointer; | |
| } | |
| .bpm-display { | |
| width: 60px; | |
| text-align: center; | |
| font-size: 24px; | |
| font-weight: bold; | |
| } | |
| .transport-btn { | |
| width: 60px; | |
| height: 60px; | |
| border-radius: 50%; | |
| background: linear-gradient(135deg, #f44336, #b71c1c); | |
| border: none; | |
| color: white; | |
| font-size: 28px; | |
| cursor: pointer; | |
| box-shadow: 0 0 20px rgba(244, 67, 54, 0.4); | |
| } | |
| .transport-btn.playing { | |
| background: linear-gradient(135deg, #4CAF50, #1b5e20); | |
| box-shadow: 0 0 20px rgba(76, 175, 80, 0.4); | |
| } | |
| .loop-toggle { | |
| width: 60px; | |
| height: 60px; | |
| border-radius: 12px; | |
| background: rgba(255,255,255,0.1); | |
| border: 2px solid rgba(255,255,255,0.24); | |
| color: rgba(255,255,255,0.38); | |
| font-size: 28px; | |
| cursor: pointer; | |
| } | |
| .loop-toggle.active { | |
| background: #2196F3; | |
| border-color: white; | |
| color: white; | |
| } | |
| .action-row { | |
| display: flex; | |
| justify-content: space-around; | |
| flex-wrap: wrap; | |
| gap: 8px; | |
| } | |
| .action-btn { | |
| display: flex; | |
| flex-direction: column; | |
| align-items: center; | |
| gap: 4px; | |
| padding: 12px; | |
| background: rgba(255,255,255,0.1); | |
| border: 1px solid rgba(255,255,255,0.24); | |
| border-radius: 8px; | |
| color: white; | |
| cursor: pointer; | |
| font-size: 20px; | |
| min-width: 80px; | |
| } | |
| .action-btn.recording { | |
| background: rgba(244, 67, 54, 0.5); | |
| border-color: #f44336; | |
| animation: recordBtnPulse 0.5s infinite; | |
| } | |
| @keyframes recordBtnPulse { | |
| 0%, 100% { transform: scale(1); } | |
| 50% { transform: scale(1.05); } | |
| } | |
| .action-btn.bluetooth-connected { | |
| background: rgba(76, 175, 80, 0.5); | |
| border-color: #4CAF50; | |
| } | |
| .action-btn span { | |
| font-size: 10px; | |
| color: rgba(255,255,255,0.7); | |
| } | |
| .action-btn:hover { | |
| background: rgba(255,255,255,0.2); | |
| } | |
| /* Recording Panel */ | |
| .recording-panel { | |
| background: rgba(0,0,0,0.38); | |
| border-radius: 16px; | |
| border: 1px solid rgba(255,255,255,0.1); | |
| padding: 16px; | |
| margin-bottom: 16px; | |
| display: none; | |
| } | |
| .recording-panel.active { | |
| display: block; | |
| } | |
| .recording-controls { | |
| display: flex; | |
| justify-content: space-around; | |
| align-items: center; | |
| } | |
| .record-btn { | |
| width: 70px; | |
| height: 70px; | |
| border-radius: 50%; | |
| background: linear-gradient(135deg, #f44336, #b71c1c); | |
| border: 3px solid white; | |
| color: white; | |
| font-size: 14px; | |
| font-weight: bold; | |
| cursor: pointer; | |
| box-shadow: 0 0 30px rgba(244, 67, 54, 0.6); | |
| display: flex; | |
| flex-direction: column; | |
| align-items: center; | |
| justify-content: center; | |
| gap: 4px; | |
| } | |
| .record-btn.recording { | |
| animation: recordBtnPulse 0.5s infinite; | |
| } | |
| .record-btn span { | |
| font-size: 10px; | |
| } | |
| .level-meter { | |
| width: 150px; | |
| height: 20px; | |
| background: rgba(0,0,0,0.5); | |
| border-radius: 10px; | |
| overflow: hidden; | |
| display: flex; | |
| } | |
| .level-bar { | |
| height: 100%; | |
| background: linear-gradient(90deg, #4CAF50, #FFEB3B, #f44336); | |
| width: 0%; | |
| transition: width 0.05s; | |
| } | |
| /* Bluetooth Modal */ | |
| .modal-overlay { | |
| position: fixed; | |
| top: 0; | |
| left: 0; | |
| right: 0; | |
| bottom: 0; | |
| background: rgba(0,0,0,0.8); | |
| display: none; | |
| align-items: center; | |
| justify-content: center; | |
| z-index: 3000; | |
| } | |
| .modal-overlay.active { | |
| display: flex; | |
| } | |
| .modal-content { | |
| background: #16213E; | |
| border-radius: 16px; | |
| padding: 24px; | |
| max-width: 400px; | |
| width: 90%; | |
| border: 1px solid rgba(255,255,255,0.2); | |
| } | |
| .modal-header { | |
| display: flex; | |
| justify-content: space-between; | |
| align-items: center; | |
| margin-bottom: 20px; | |
| } | |
| .modal-header h3 { | |
| font-size: 20px; | |
| } | |
| .modal-close { | |
| background: none; | |
| border: none; | |
| color: white; | |
| font-size: 24px; | |
| cursor: pointer; | |
| } | |
| .device-list { | |
| max-height: 200px; | |
| overflow-y: auto; | |
| margin-bottom: 20px; | |
| } | |
| .device-item { | |
| padding: 12px; | |
| background: rgba(255,255,255,0.05); | |
| border-radius: 8px; | |
| margin-bottom: 8px; | |
| cursor: pointer; | |
| display: flex; | |
| justify-content: space-between; | |
| align-items: center; | |
| } | |
| .device-item:hover { | |
| background: rgba(255,255,255,0.1); | |
| } | |
| .device-item.connected { | |
| background: rgba(76, 175, 80, 0.3); | |
| border: 1px solid #4CAF50; | |
| } | |
| .device-status { | |
| font-size: 12px; | |
| color: rgba(255,255,255,0.5); | |
| } | |
| /* Toast notification */ | |
| .toast { | |
| position: fixed; | |
| bottom: 20px; | |
| left: 50%; | |
| transform: translateX(-50%) translateY(100px); | |
| background: #333; | |
| color: white; | |
| padding: 12px 24px; | |
| border-radius: 8px; | |
| opacity: 0; | |
| transition: all 0.3s; | |
| z-index: 1000; | |
| } | |
| .toast.show { | |
| transform: translateX(-50%) translateY(0); | |
| opacity: 1; | |
| } | |
| /* File input hidden */ | |
| #fileInput { | |
| display: none; | |
| } | |
| /* Welcome overlay */ | |
| .welcome-overlay { | |
| position: fixed; | |
| top: 0; | |
| left: 0; | |
| right: 0; | |
| bottom: 0; | |
| background: rgba(0,0,0,0.9); | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| z-index: 2000; | |
| cursor: pointer; | |
| } | |
| .welcome-content { | |
| text-align: center; | |
| padding: 40px; | |
| } | |
| .welcome-content h2 { | |
| font-size: 36px; | |
| margin-bottom: 20px; | |
| background: linear-gradient(90deg, #9C27B0, #E91E63); | |
| -webkit-background-clip: text; | |
| -webkit-text-fill-color: transparent; | |
| } | |
| .welcome-content p { | |
| color: rgba(255,255,255,0.7); | |
| margin-bottom: 30px; | |
| } | |
| .tap-to-start { | |
| display: inline-block; | |
| padding: 16px 32px; | |
| background: linear-gradient(135deg, #9C27B0, #E91E63); | |
| border-radius: 32px; | |
| font-size: 18px; | |
| animation: pulse 1.5s infinite; | |
| } | |
| @keyframes pulse { | |
| 0%, 100% { transform: scale(1); } | |
| 50% { transform: scale(1.05); } | |
| } | |
| /* Input level indicator */ | |
| .input-monitor { | |
| display: flex; | |
| align-items: center; | |
| gap: 10px; | |
| margin-top: 10px; | |
| } | |
| .monitor-label { | |
| font-size: 12px; | |
| color: rgba(255,255,255,0.7); | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| <!-- Welcome Overlay (needed for audio context) --> | |
| <div class="welcome-overlay" id="welcomeOverlay"> | |
| <div class="welcome-content"> | |
| <h2>SILLYLOOPS</h2> | |
| <p>Retro ARP Sampler with Recording & Bluetooth</p> | |
| <div class="tap-to-start">🎹 Tap to Start</div> | |
| </div> | |
| </div> | |
| <div class="container"> | |
| <header> | |
| <h1>SILLYLOOPS</h1> | |
| <button class="nav-btn" onclick="showHelp()" style="width:auto;padding:0 12px;">⚙️</button> | |
| </header> | |
| <!-- LCD Display --> | |
| <div class="lcd-display"> | |
| <div class="lcd-content"> | |
| <div class="lcd-icon">📊</div> | |
| <div class="lcd-info"> | |
| <div class="lcd-row"> | |
| <div> | |
| <div class="lcd-label">BANK</div> | |
| <div class="lcd-value" id="bankDisplay">A</div> | |
| </div> | |
| <div> | |
| <div class="lcd-label">BPM</div> | |
| <div class="lcd-value" id="bpmDisplay">120</div> | |
| </div> | |
| <div> | |
| <div class="lcd-label">STATUS</div> | |
| <div class="lcd-value" id="statusDisplay">READY</div> | |
| </div> | |
| <div> | |
| <div class="lcd-label">LOOP</div> | |
| <div class="lcd-value" id="loopDisplay">OFF</div> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- Recording Panel --> | |
| <div class="recording-panel" id="recordingPanel"> | |
| <div class="recording-controls"> | |
| <div> | |
| <div class="control-label">MIC INPUT</div> | |
| <div class="input-monitor"> | |
| <div class="level-meter"> | |
| <div class="level-bar" id="levelBar"></div> | |
| </div> | |
| <span id="levelValue">0%</span> | |
| </div> | |
| </div> | |
| <button class="record-btn" id="recordBtn" onclick="toggleRecording()"> | |
| 🎤 | |
| <span id="recordBtnText">REC</span> | |
| </button> | |
| <div> | |
| <div class="control-label">RECORD TIME</div> | |
| <div class="bpm-display" id="recordTime" style="font-size:18px;">0.0s</div> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- Bank Selector --> | |
| <div class="bank-selector"> | |
| <div class="bank-label">BANK:</div> | |
| <div class="bank-buttons"> | |
| <button class="bank-btn active" data-bank="0" onclick="selectBank(0)">A</button> | |
| <button class="bank-btn" data-bank="1" onclick="selectBank(1)">B</button> | |
| <button class="bank-btn" data-bank="2" onclick="selectBank(2)">C</button> | |
| <button class="bank-btn" data-bank="3" onclick="selectBank(3)">D</button> | |
| </div> | |
| <button class="nav-btn" onclick="previousBank()">◀</button> | |
| <button class="nav-btn" onclick="nextBank()">▶</button> | |
| </div> | |
| <!-- Pad Grid --> | |
| <div class="pad-grid" id="padGrid"> | |
| <!-- Pads generated by JavaScript --> | |
| </div> | |
| <!-- Control Panel --> | |
| <div class="control-panel"> | |
| <div class="control-row"> | |
| <div class="control-group"> | |
| <div class="control-label">BPM</div> | |
| <div class="bpm-control"> | |
| <button class="bpm-btn" onclick="changeBpm(-5)">−</button> | |
| <div class="bpm-display" id="bpmControl">120</div> | |
| <button class="bpm-btn" onclick="changeBpm(5)">+</button> | |
| </div> | |
| </div> | |
| <div class="control-group"> | |
| <div class="control-label">TRANSPORT</div> | |
| <button class="transport-btn" id="transportBtn" onclick="toggleTransport()">▶</button> | |
| </div> | |
| <div class="control-group"> | |
| <div class="control-label">LOOP</div> | |
| <button class="loop-toggle" id="loopToggle" onclick="toggleLoopMode()">⟳</button> | |
| </div> | |
| </div> | |
| <div class="action-row"> | |
| <button class="action-btn" onclick="triggerImport()"> | |
| 📁 | |
| <span>Import</span> | |
| </button> | |
| <button class="action-btn" onclick="showRecordingPanel()"> | |
| 🎤 | |
| <span>Record</span> | |
| </button> | |
| <button class="action-btn" id="bluetoothBtn" onclick="showBluetoothModal()"> | |
| 📶 | |
| <span>Bluetooth</span> | |
| </button> | |
| <button class="action-btn" onclick="loadDefaultSamples()"> | |
| 📦 | |
| <span>Load Pack</span> | |
| </button> | |
| <button class="action-btn" onclick="clearCurrentPad()"> | |
| ✕ | |
| <span>Clear</span> | |
| </button> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- Hidden file input --> | |
| <input type="file" id="fileInput" accept="audio/*" multiple> | |
| <!-- Bluetooth Modal --> | |
| <div class="modal-overlay" id="bluetoothModal"> | |
| <div class="modal-content"> | |
| <div class="modal-header"> | |
| <h3>📶 Bluetooth Devices</h3> | |
| <button class="modal-close" onclick="closeBluetoothModal()">×</button> | |
| </div> | |
| <div class="device-list" id="deviceList"> | |
| <div class="device-item" onclick="scanBluetooth()"> | |
| <span>🔍 Scan for Devices</span> | |
| <span class="device-status">Click to scan</span> | |
| </div> | |
| </div> | |
| <div style="text-align:center;color:rgba(255,255,255,0.5);font-size:12px;"> | |
| Supports Web MIDI and Web Bluetooth API | |
| </div> | |
| </div> | |
| </div> | |
| <!-- Toast --> | |
| <div class="toast" id="toast"></div> | |
| <script> | |
| // Audio Context | |
| let audioContext; | |
| let isAudioInitialized = false; | |
| // State | |
| let currentBank = 0; | |
| let bpm = 120; | |
| let isPlaying = false; | |
| let loopMode = false; | |
| let currentPadIndex = -1; | |
| // Recording state | |
| let mediaStream = null; | |
| let mediaRecorder = null; | |
| let audioChunks = []; | |
| let isRecording = false; | |
| let recordingStartTime = 0; | |
| let recordingTimer = null; | |
| let analyser = null; | |
| let dataArray = null; | |
| // Bluetooth state | |
| let bluetoothDevices = []; | |
| let connectedMidiAccess = null; | |
| let connectedBluetoothDevice = null; | |
| // Sample data: 4 banks x 8 pads | |
| const samples = { | |
| 0: [null, null, null, null, null, null, null, null], | |
| 1: [null, null, null, null, null, null, null, null], | |
| 2: [null, null, null, null, null, null, null, null], | |
| 3: [null, null, null, null, null, null, null, null] | |
| }; | |
| // Pad colors | |
| const padColors = [ | |
| { color: '#FF6B6B', dark: '#c0392b' }, | |
| { color: '#4ECDC4', dark: '#16a085' }, | |
| { color: '#FFE66D', dark: '#f1c40f' }, | |
| { color: '#95E1D3', dark: '#1abc9c' }, | |
| { color: '#F38181', dark: '#e74c3c' }, | |
| { color: '#AA96DA', dark: '#9b59b6' }, | |
| { color: '#FCBAD3', dark: '#e91e63' }, | |
| { color: '#A8D8EA', dark: '#3498db' } | |
| ]; | |
| // Default sample names | |
| const defaultNames = [ | |
| 'Kick', 'Snare', 'HiHat Closed', 'HiHat Open', | |
| 'Clap', 'Percussion', 'Crash', 'Ride' | |
| ]; | |
| // Initialize audio context | |
| function initAudio() { | |
| if (!isAudioInitialized) { | |
| audioContext = new (window.AudioContext || window.webkitAudioContext)(); | |
| isAudioInitialized = true; | |
| } | |
| if (audioContext.state === 'suspended') { | |
| audioContext.resume(); | |
| } | |
| } | |
| // Play a synthesized drum sound | |
| function playSound(padIndex) { | |
| initAudio(); | |
| const now = audioContext.currentTime; | |
| const sample = samples[currentBank][padIndex]; | |
| // If custom sample loaded, play it | |
| if (sample && sample.buffer) { | |
| playBuffer(sample.buffer, loopMode); | |
| highlightPad(padIndex); | |
| updateStatus('PLAYING', true); | |
| setTimeout(() => { if (!isPlaying) updateStatus('READY'); }, 200); | |
| return; | |
| } | |
| // Otherwise play synthesized sound based on pad index | |
| const sounds = [ | |
| () => playKick(now), | |
| () => playSnare(now), | |
| () => playHiHatClosed(now), | |
| () => playHiHatOpen(now), | |
| () => playClap(now), | |
| () => playPercussion(now), | |
| () => playCrash(now), | |
| () => playRide(now) | |
| ]; | |
| sounds[padIndex](); | |
| // Visual feedback | |
| highlightPad(padIndex); | |
| updateStatus('PLAYING', true); | |
| // Send MIDI note if connected | |
| sendMidiNote(padIndex + 36); | |
| setTimeout(() => { | |
| if (!isPlaying) updateStatus('READY'); | |
| }, 200); | |
| } | |
| function playBuffer(buffer, loop) { | |
| const source = audioContext.createBufferSource(); | |
| source.buffer = buffer; | |
| source.connect(audioContext.destination); | |
| source.loop = loop; | |
| source.start(0); | |
| if (!loop) { | |
| source.onended = () => { | |
| if (!isPlaying) updateStatus('READY'); | |
| }; | |
| } | |
| } | |
| // Synthesized drum sounds | |
| function playKick(t) { | |
| const osc = audioContext.createOscillator(); | |
| const gain = audioContext.createGain(); | |
| osc.connect(gain); | |
| gain.connect(audioContext.destination); | |
| osc.frequency.setValueAtTime(150, t); | |
| osc.frequency.exponentialRampToValueAtTime(0.01, t + 0.5); | |
| gain.gain.setValueAtTime(1, t); | |
| gain.gain.exponentialRampToValueAtTime(0.01, t + 0.5); | |
| osc.start(t); | |
| osc.stop(t + 0.5); | |
| } | |
| function playSnare(t) { | |
| const noise = createNoise(); | |
| const noiseFilter = audioContext.createBiquadFilter(); | |
| const noiseGain = audioContext.createGain(); | |
| noiseFilter.type = 'highpass'; | |
| noiseFilter.frequency.value = 1000; | |
| noise.connect(noiseFilter); | |
| noiseFilter.connect(noiseGain); | |
| noiseGain.connect(audioContext.destination); | |
| noiseGain.gain.setValueAtTime(1, t); | |
| noiseGain.gain.exponentialRampToValueAtTime(0.01, t + 0.2); | |
| noise.start(t); | |
| noise.stop(t + 0.2); | |
| // Add tone | |
| const osc = audioContext.createOscillator(); | |
| const gain = audioContext.createGain(); | |
| osc.type = 'triangle'; | |
| osc.connect(gain); | |
| gain.connect(audioContext.destination); | |
| osc.frequency.setValueAtTime(250, t); | |
| gain.gain.setValueAtTime(0.5, t); | |
| gain.gain.exponentialRampToValueAtTime(0.01, t + 0.1); | |
| osc.start(t); | |
| osc.stop(t + 0.1); | |
| } | |
| function playHiHatClosed(t) { | |
| const noise = createNoise(); | |
| const filter = audioContext.createBiquadFilter(); | |
| const gain = audioContext.createGain(); | |
| filter.type = 'highpass'; | |
| filter.frequency.value = 7000; | |
| noise.connect(filter); | |
| filter.connect(gain); | |
| gain.connect(audioContext.destination); | |
| gain.gain.setValueAtTime(0.3, t); | |
| gain.gain.exponentialRampToValueAtTime(0.01, t + 0.05); | |
| noise.start(t); | |
| noise.stop(t + 0.05); | |
| } | |
| function playHiHatOpen(t) { | |
| const noise = createNoise(); | |
| const filter = audioContext.createBiquadFilter(); | |
| const gain = audioContext.createGain(); | |
| filter.type = 'highpass'; | |
| filter.frequency.value = 7000; | |
| noise.connect(filter); | |
| filter.connect(gain); | |
| gain.connect(audioContext.destination); | |
| gain.gain.setValueAtTime(0.3, t); | |
| gain.gain.exponentialRampToValueAtTime(0.01, t + 0.3); | |
| noise.start(t); | |
| noise.stop(t + 0.3); | |
| } | |
| function playClap(t) { | |
| const noise = createNoise(); | |
| const filter = audioContext.createBiquadFilter(); | |
| const gain = audioContext.createGain(); | |
| filter.type = 'bandpass'; | |
| filter.frequency.value = 1500; | |
| noise.connect(filter); | |
| filter.connect(gain); | |
| gain.connect(audioContext.destination); | |
| gain.gain.setValueAtTime(0, t); | |
| gain.gain.linearRampToValueAtTime(0.8, t + 0.01); | |
| gain.gain.exponentialRampToValueAtTime(0.01, t + 0.15); | |
| noise.start(t); | |
| noise.stop(t + 0.15); | |
| } | |
| function playPercussion(t) { | |
| const osc = audioContext.createOscillator(); | |
| const gain = audioContext.createGain(); | |
| osc.type = 'square'; | |
| osc.connect(gain); | |
| gain.connect(audioContext.destination); | |
| osc.frequency.setValueAtTime(400, t); | |
| gain.gain.setValueAtTime(0.3, t); | |
| gain.gain.exponentialRampToValueAtTime(0.01, t + 0.1); | |
| osc.start(t); | |
| osc.stop(t + 0.1); | |
| } | |
| function playCrash(t) { | |
| const noise = createNoise(); | |
| const filter = audioContext.createBiquadFilter(); | |
| const gain = audioContext.createGain(); | |
| filter.type = 'highpass'; | |
| filter.frequency.value = 3000; | |
| noise.connect(filter); | |
| filter.connect(gain); | |
| gain.connect(audioContext.destination); | |
| gain.gain.setValueAtTime(0.4, t); | |
| gain.gain.exponentialRampToValueAtTime(0.01, t + 1); | |
| noise.start(t); | |
| noise.stop(t + 1); | |
| } | |
| function playRide(t) { | |
| const osc = audioContext.createOscillator(); | |
| const gain = audioContext.createGain(); | |
| osc.type = 'sine'; | |
| osc.connect(gain); | |
| gain.connect(audioContext.destination); | |
| osc.frequency.setValueAtTime(800, t); | |
| gain.gain.setValueAtTime(0.3, t); | |
| gain.gain.exponentialRampToValueAtTime(0.01, t + 0.5); | |
| osc.start(t); | |
| osc.stop(t + 0.5); | |
| } | |
| function createNoise() { | |
| const bufferSize = audioContext.sampleRate * 2; | |
| const buffer = audioContext.createBuffer(1, bufferSize, audioContext.sampleRate); | |
| const data = buffer.getChannelData(0); | |
| for (let i = 0; i < bufferSize; i++) { | |
| data[i] = Math.random() * 2 - 1; | |
| } | |
| const noise = audioContext.createBufferSource(); | |
| noise.buffer = buffer; | |
| return noise; | |
| } | |
| // ============ RECORDING FUNCTIONS ============ | |
| async function showRecordingPanel() { | |
| initAudio(); | |
| const panel = document.getElementById('recordingPanel'); | |
| panel.classList.toggle('active'); | |
| if (panel.classList.contains('active') && !mediaStream) { | |
| await initMicrophone(); | |
| } | |
| } | |
| async function initMicrophone() { | |
| try { | |
| mediaStream = await navigator.mediaDevices.getUserMedia({ | |
| audio: { | |
| echoCancellation: false, | |
| noiseSuppression: false, | |
| autoGainControl: false | |
| } | |
| }); | |
| // Setup analyser for level meter | |
| analyser = audioContext.createAnalyser(); | |
| analyser.fftSize = 256; | |
| const source = audioContext.createMediaStreamSource(mediaStream); | |
| source.connect(analyser); | |
| dataArray = new Uint8Array(analyser.frequencyBinCount); | |
| updateLevelMeter(); | |
| showToast('Microphone connected'); | |
| } catch (err) { | |
| console.error('Microphone error:', err); | |
| showToast('Microphone access denied'); | |
| } | |
| } | |
| function updateLevelMeter() { | |
| if (!analyser || !dataArray) return; | |
| analyser.getByteFrequencyData(dataArray); | |
| let sum = 0; | |
| for (let i = 0; i < dataArray.length; i++) { | |
| sum += dataArray[i]; | |
| } | |
| const average = sum / dataArray.length; | |
| const percentage = Math.round((average / 255) * 100); | |
| document.getElementById('levelBar').style.width = percentage + '%'; | |
| document.getElementById('levelValue').textContent = percentage + '%'; | |
| requestAnimationFrame(updateLevelMeter); | |
| } | |
| async function toggleRecording() { | |
| if (isRecording) { | |
| stopRecording(); | |
| } else { | |
| startRecording(); | |
| } | |
| } | |
| async function startRecording() { | |
| if (!mediaStream) { | |
| await initMicrophone(); | |
| } | |
| if (!mediaStream) { | |
| showToast('Microphone not available'); | |
| return; | |
| } | |
| audioChunks = []; | |
| mediaRecorder = new MediaRecorder(mediaStream); | |
| mediaRecorder.ondataavailable = (event) => { | |
| audioChunks.push(event.data); | |
| }; | |
| mediaRecorder.onstop = async () => { | |
| const audioBlob = new Blob(audioChunks, { type: 'audio/wav' }); | |
| const arrayBuffer = await audioBlob.arrayBuffer(); | |
| try { | |
| const audioBuffer = await audioContext.decodeAudioData(arrayBuffer); | |
| // Assign to current pad | |
| samples[currentBank][currentPadIndex >= 0 ? currentPadIndex : 0] = { | |
| name: 'Recording ' + new Date().toLocaleTimeString(), | |
| buffer: audioBuffer, | |
| loop: loopMode | |
| }; | |
| renderPads(); | |
| showToast('Recording saved to pad!'); | |
| } catch (err) { | |
| showToast('Error processing recording'); | |
| console.error(err); | |
| } | |
| }; | |
| mediaRecorder.start(); | |
| isRecording = true; | |
| recordingStartTime = Date.now(); | |
| // Update UI | |
| const recordBtn = document.getElementById('recordBtn'); | |
| recordBtn.classList.add('recording'); | |
| document.getElementById('recordBtnText').textContent = 'STOP'; | |
| // Highlight current pad | |
| if (currentPadIndex >= 0) { | |
| const pads = document.querySelectorAll('.drum-pad'); | |
| pads[currentPadIndex].classList.add('recording'); | |
| } | |
| // Start timer | |
| recordingTimer = setInterval(() => { | |
| const elapsed = (Date.now() - recordingStartTime) / 1000; | |
| document.getElementById('recordTime').textContent = elapsed.toFixed(1) + 's'; | |
| }, 100); | |
| updateStatus('RECORDING', true); | |
| showToast('Recording...'); | |
| } | |
| function stopRecording() { | |
| if (mediaRecorder && isRecording) { | |
| mediaRecorder.stop(); | |
| } | |
| isRecording = false; | |
| clearInterval(recordingTimer); | |
| // Update UI | |
| const recordBtn = document.getElementById('recordBtn'); | |
| recordBtn.classList.remove('recording'); | |
| document.getElementById('recordBtnText').textContent = 'REC'; | |
| document.getElementById('recordTime').textContent = '0.0s'; | |
| // Remove recording highlight from pads | |
| document.querySelectorAll('.drum-pad').forEach(pad => { | |
| pad.classList.remove('recording'); | |
| }); | |
| if (!isPlaying) updateStatus('READY'); | |
| showToast('Recording stopped'); | |
| } | |
| // ============ BLUETOOTH FUNCTIONS ============ | |
| async function showBluetoothModal() { | |
| document.getElementById('bluetoothModal').classList.add('active'); | |
| } | |
| function closeBluetoothModal() { | |
| document.getElementById('bluetoothModal').classList.remove('active'); | |
| } | |
| async function scanBluetooth() { | |
| try { | |
| // Request Web MIDI access | |
| if (navigator.requestMIDIAccess) { | |
| connectedMidiAccess = await navigator.requestMIDIAccess({ sysex: true }); | |
| console.log('MIDI Access granted'); | |
| connectedMidiAccess.onstatechange = (event) => { | |
| console.log('MIDI state change:', event.port.name, event.port.state); | |
| updateDeviceList(); | |
| }; | |
| // List inputs | |
| const inputs = connectedMidiAccess.inputs; | |
| bluetoothDevices = []; | |
| inputs.forEach((input) => { | |
| bluetoothDevices.push({ | |
| name: input.name, | |
| type: 'midi', | |
| id: input.id, | |
| connected: true | |
| }); | |
| }); | |
| if (bluetoothDevices.length === 0) { | |
| showToast('No MIDI devices found'); | |
| } else { | |
| showToast(`Found ${bluetoothDevices.length} MIDI device(s)`); | |
| } | |
| updateDeviceList(); | |
| } else { | |
| showToast('Web MIDI not supported in this browser'); | |
| } | |
| } catch (err) { | |
| console.error('Bluetooth/MIDI error:', err); | |
| showToast('Bluetooth scan failed: ' + err.message); | |
| } | |
| } | |
| function updateDeviceList() { | |
| const deviceList = document.getElementById('deviceList'); | |
| deviceList.innerHTML = ''; | |
| if (bluetoothDevices.length === 0) { | |
| deviceList.innerHTML = ` | |
| <div class="device-item" onclick="scanBluetooth()"> | |
| <span>🔍 Scan for Devices</span> | |
| <span class="device-status">Click to scan</span> | |
| </div> | |
| `; | |
| return; | |
| } | |
| bluetoothDevices.forEach((device, index) => { | |
| const item = document.createElement('div'); | |
| item.className = 'device-item' + (device.connected ? ' connected' : ''); | |
| item.innerHTML = ` | |
| <span>🎹 ${device.name}</span> | |
| <span class="device-status">${device.connected ? 'Connected' : 'Disconnected'}</span> | |
| `; | |
| item.onclick = () => selectBluetoothDevice(index); | |
| deviceList.appendChild(item); | |
| }); | |
| } | |
| function selectBluetoothDevice(index) { | |
| const device = bluetoothDevices[index]; | |
| showToast(`Selected: ${device.name}`); | |
| closeBluetoothModal(); | |
| // Update button | |
| const btn = document.getElementById('bluetoothBtn'); | |
| btn.classList.add('bluetooth-connected'); | |
| btn.innerHTML = '📶<span>Connected</span>'; | |
| } | |
| function sendMidiNote(noteNumber) { | |
| if (!connectedMidiAccess) return; | |
| const outputs = connectedMidiAccess.outputs; | |
| outputs.forEach((output) => { | |
| // Note On | |
| output.send([0x90, noteNumber, 127]); | |
| // Note Off after 100ms | |
| setTimeout(() => { | |
| output.send([0x80, noteNumber, 0]); | |
| }, 100); | |
| }); | |
| } | |
| // ============ UI FUNCTIONS ============ | |
| function renderPads() { | |
| const grid = document.getElementById('padGrid'); | |
| grid.innerHTML = ''; | |
| for (let i = 0; i < 8; i++) { | |
| const pad = document.createElement('div'); | |
| pad.className = 'drum-pad'; | |
| pad.style.setProperty('--pad-color', padColors[i].color); | |
| pad.style.setProperty('--pad-color-dark', padColors[i].dark); | |
| pad.dataset.index = i; | |
| const sample = samples[currentBank][i]; | |
| const rawName = sample ? sample.name : defaultNames[i]; | |
| // Sanitize name for display | |
| const name = rawName.replace(/[&<>"']/g, function(m) { | |
| return { | |
| '&': '&', | |
| '<': '<', | |
| '>': '>', | |
| '"': '"', | |
| "'": ''' | |
| }[m]; | |
| }); | |
| pad.innerHTML = ` | |
| <div class="mic-indicator">🎤</div> | |
| <div class="loop-indicator"></div> | |
| <div class="number">${i + 1}</div> | |
| <div class="name">${name.length > 12 ? name.substring(0, 10) + '...' : name}</div> | |
| `; | |
| if (sample && sample.loop) { | |
| pad.classList.add('loop'); | |
| } | |
| if (sample && sample.buffer && sample.name.includes('Recording')) { | |
| pad.classList.add('has-mic'); | |
| } | |
| // Mouse events | |
| pad.addEventListener('mousedown', () => { | |
| currentPadIndex = i; | |
| playSound(i); | |
| }); | |
| pad.addEventListener('contextmenu', (e) => { | |
| e.preventDefault(); | |
| triggerImport(i); | |
| }); | |
| // Touch events | |
| pad.addEventListener('touchstart', (e) => { | |
| e.preventDefault(); | |
| currentPadIndex = i; | |
| playSound(i); | |
| }); | |
| grid.appendChild(pad); | |
| } | |
| } | |
| function highlightPad(index) { | |
| const pads = document.querySelectorAll('.drum-pad'); | |
| pads.forEach((pad, i) => { | |
| if (i === index) { | |
| pad.classList.add('playing'); | |
| setTimeout(() => pad.classList.remove('playing'), 150); | |
| } | |
| }); | |
| } | |
| function selectBank(bank) { | |
| currentBank = bank; | |
| document.querySelectorAll('.bank-btn').forEach((btn, i) => { | |
| btn.classList.toggle('active', i === bank); | |
| }); | |
| document.getElementById('bankDisplay').textContent = String.fromCharCode(65 + bank); | |
| renderPads(); | |
| } | |
| function previousBank() { | |
| currentBank = (currentBank - 1 + 4) % 4; | |
| selectBank(currentBank); | |
| } | |
| function nextBank() { | |
| currentBank = (currentBank + 1) % 4; | |
| selectBank(currentBank); | |
| } | |
| function changeBpm(delta) { | |
| bpm = Math.max(60, Math.min(200, bpm + delta)); | |
| document.getElementById('bpmDisplay').textContent = bpm; | |
| document.getElementById('bpmControl').textContent = bpm; | |
| } | |
| function toggleTransport() { | |
| isPlaying = !isPlaying; | |
| const btn = document.getElementById('transportBtn'); | |
| btn.classList.toggle('playing', isPlaying); | |
| btn.textContent = isPlaying ? '⏹' : '▶'; | |
| updateStatus(isPlaying ? 'PLAYING' : 'READY', isPlaying); | |
| if (!isPlaying) { | |
| if (audioContext) { | |
| audioContext.suspend(); | |
| audioContext.resume(); | |
| } | |
| } | |
| } | |
| function toggleLoopMode() { | |
| loopMode = !loopMode; | |
| document.getElementById('loopToggle').classList.toggle('active', loopMode); | |
| document.getElementById('loopDisplay').textContent = loopMode ? 'ON' : 'OFF'; | |
| document.getElementById('loopDisplay').style.color = loopMode ? '#FFC107' : '#4ECDC4'; | |
| showToast(loopMode ? 'Loop mode enabled' : 'Loop mode disabled'); | |
| } | |
| function updateStatus(status, blink = false) { | |
| const display = document.getElementById('statusDisplay'); | |
| display.textContent = status; | |
| display.classList.toggle('active', blink); | |
| } | |
| function showToast(message) { | |
| const toast = document.getElementById('toast'); | |
| toast.textContent = message; | |
| toast.classList.add('show'); | |
| setTimeout(() => toast.classList.remove('show'), 2000); | |
| } | |
| function triggerImport(padIndex = null) { | |
| currentPadIndex = padIndex; | |
| document.getElementById('fileInput').click(); | |
| } | |
| function loadDefaultSamples() { | |
| for (let i = 0; i < 8; i++) { | |
| samples[0][i] = { | |
| name: defaultNames[i], | |
| buffer: null, | |
| loop: false | |
| }; | |
| } | |
| renderPads(); | |
| showToast('Default samples loaded to Bank A'); | |
| } | |
| function clearCurrentPad() { | |
| if (currentPadIndex !== null) { | |
| samples[currentBank][currentPadIndex] = null; | |
| renderPads(); | |
| showToast('Pad cleared'); | |
| } else { | |
| showToast('Select a pad first (tap or right-click)'); | |
| } | |
| } | |
| function showHelp() { | |
| alert('SillyLoops Controls:\n\n' + | |
| '• Tap pads to play sounds\n' + | |
| '• Right-click (or long-press) to import samples\n' + | |
| '• Use Bank buttons (A-D) to switch banks\n' + | |
| '• Adjust BPM with +/- buttons\n' + | |
| '• Toggle loop mode with ⟳ button\n' + | |
| '• 🎤 Record: Use microphone to record samples\n' + | |
| '• 📶 Bluetooth: Connect MIDI controllers\n' + | |
| '• Load default samples with "Load Pack"\n\n' + | |
| 'Keyboard Shortcuts:\n' + | |
| '• 1-8: Play pads\n' + | |
| '• Q,W,E,R: Select banks\n' + | |
| '• Space: Play/Stop\n' + | |
| '• L: Toggle loop\n' + | |
| '• ↑/↓: Adjust BPM'); | |
| } | |
| // File input handler | |
| document.getElementById('fileInput').addEventListener('change', async (e) => { | |
| const file = e.target.files[0]; | |
| if (!file) return; | |
| const padIndex = currentPadIndex !== null ? currentPadIndex : 0; | |
| try { | |
| const arrayBuffer = await file.arrayBuffer(); | |
| const audioBuffer = await audioContext.decodeAudioData(arrayBuffer); | |
| samples[currentBank][padIndex] = { | |
| name: file.name.replace(/\.[^/.]+$/, ''), | |
| buffer: audioBuffer, | |
| loop: loopMode | |
| }; | |
| renderPads(); | |
| showToast(`Loaded: ${file.name}`); | |
| } catch (err) { | |
| showToast('Error loading audio file'); | |
| console.error(err); | |
| } | |
| e.target.value = ''; | |
| currentPadIndex = null; | |
| }); | |
| // Welcome overlay handler | |
| document.getElementById('welcomeOverlay').addEventListener('click', function() { | |
| initAudio(); | |
| this.style.opacity = '0'; | |
| setTimeout(() => this.style.display = 'none', 300); | |
| }); | |
| // Keyboard shortcuts | |
| document.addEventListener('keydown', (e) => { | |
| const key = e.key.toLowerCase(); | |
| // Number keys 1-8 play pads | |
| if (key >= '1' && key <= '8') { | |
| currentPadIndex = parseInt(key) - 1; | |
| playSound(currentPadIndex); | |
| } | |
| // Q, W, E, R select banks | |
| if (key === 'q') selectBank(0); | |
| if (key === 'w') selectBank(1); | |
| if (key === 'e') selectBank(2); | |
| if (key === 'r') selectBank(3); | |
| // Space toggles play | |
| if (key === ' ') { | |
| e.preventDefault(); | |
| toggleTransport(); | |
| } | |
| // L toggles loop | |
| if (key === 'l') toggleLoopMode(); | |
| // R starts recording | |
| if (key === 'r' && e.ctrlKey) { | |
| e.preventDefault(); | |
| showRecordingPanel(); | |
| } | |
| // Up/down adjust BPM | |
| if (key === 'arrowup') changeBpm(5); | |
| if (key === 'arrowdown') changeBpm(-5); | |
| }); | |
| // Initialize | |
| renderPads(); | |
| </script> | |
| </body> | |
| </html> | |