SillyLoops / web /standalone.html
truegleai
fix: fix AudioPlayer API and missing HapticFeedback import
b6fd29b
<!DOCTYPE html>
<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 {
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#39;'
}[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>