Spaces:
Running
Running
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <title>Sonic Canvas</title> | |
| <link rel="icon" type="image/x-icon" href="/static/favicon.ico"> | |
| <script src="https://cdn.tailwindcss.com"></script> | |
| <script src="https://unpkg.com/feather-icons"></script> | |
| <script src="https://cdn.jsdelivr.net/npm/vanta@latest/dist/vanta.waves.min.js"></script> | |
| <style> | |
| .note-cell { | |
| transition: all 0.2s ease; | |
| cursor: pointer; | |
| position: relative; | |
| overflow: hidden; | |
| } | |
| .note-cell::before { | |
| content: ''; | |
| position: absolute; | |
| top: 0; | |
| left: 0; | |
| width: 100%; | |
| height: 100%; | |
| background: radial-gradient(circle, rgba(255,255,255,0.1) 0%, transparent 80%); | |
| opacity: 0; | |
| transition: opacity 0.3s ease; | |
| } | |
| .note-cell:hover::before { | |
| opacity: 1; | |
| } | |
| .note-cell:hover { | |
| transform: scale(1.05); | |
| z-index: 10; | |
| } | |
| .active-note { | |
| animation: pulse 0.5s ease-in-out; | |
| box-shadow: 0 0 15px rgba(139, 92, 246, 0.7); | |
| } | |
| @keyframes pulse { | |
| 0% { transform: scale(1); } | |
| 50% { transform: scale(1.15); } | |
| 100% { transform: scale(1); } | |
| } | |
| .playing-column { | |
| background-color: rgba(139, 92, 246, 0.3) ; | |
| box-shadow: inset 0 0 10px rgba(139, 92, 246, 0.5); | |
| } | |
| .keyboard-container { | |
| position: relative; | |
| height: 150px; | |
| display: flex; | |
| padding: 0 10px; | |
| } | |
| .white-key { | |
| position: relative; | |
| width: 50px; | |
| height: 100%; | |
| background: linear-gradient(to bottom, #fff 0%, #f5f5f5 100%); | |
| border: 1px solid #ccc; | |
| border-radius: 0 0 5px 5px; | |
| margin-right: -1px; | |
| z-index: 1; | |
| display: flex; | |
| align-items: flex-end; | |
| justify-content: center; | |
| padding-bottom: 10px; | |
| font-size: 12px; | |
| color: #333; | |
| cursor: pointer; | |
| transition: all 0.1s ease; | |
| box-shadow: 0 5px 5px rgba(0,0,0,0.2); | |
| } | |
| .white-key.active { | |
| background: linear-gradient(to bottom, #e0e0e0 0%, #d0d0d0 100%); | |
| transform: translateY(3px); | |
| box-shadow: 0 2px 2px rgba(0,0,0,0.2); | |
| } | |
| .black-key { | |
| position: absolute; | |
| width: 30px; | |
| height: 60%; | |
| background: linear-gradient(to bottom, #000 0%, #333 100%); | |
| border: 1px solid #000; | |
| border-radius: 0 0 3px 3px; | |
| z-index: 2; | |
| display: flex; | |
| align-items: flex-end; | |
| justify-content: center; | |
| padding-bottom: 10px; | |
| font-size: 10px; | |
| color: #fff; | |
| cursor: pointer; | |
| transition: all 0.1s ease; | |
| box-shadow: 0 3px 3px rgba(0,0,0,0.3); | |
| } | |
| .black-key.active { | |
| background: linear-gradient(to bottom, #333 0%, #555 100%); | |
| transform: translateY(2px); | |
| box-shadow: 0 1px 1px rgba(0,0,0,0.3); | |
| } | |
| .control-panel { | |
| background: rgba(31, 41, 55, 0.85); | |
| backdrop-filter: blur(10px); | |
| border-top: 1px solid rgba(255, 255, 255, 0.1); | |
| } | |
| .btn-primary { | |
| background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); | |
| border: none; | |
| transition: all 0.3s ease; | |
| box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); | |
| } | |
| .btn-primary:hover { | |
| transform: translateY(-2px); | |
| box-shadow: 0 6px 12px rgba(0, 0, 0, 0.2); | |
| } | |
| .btn-secondary { | |
| background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%); | |
| border: none; | |
| transition: all 0.3s ease; | |
| box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); | |
| } | |
| .btn-secondary:hover { | |
| transform: translateY(-2px); | |
| box-shadow: 0 6px 12px rgba(0, 0, 0, 0.2); | |
| } | |
| .slider { | |
| -webkit-appearance: none; | |
| width: 100%; | |
| height: 6px; | |
| border-radius: 3px; | |
| background: linear-gradient(90deg, #667eea, #764ba2); | |
| outline: none; | |
| } | |
| .slider::-webkit-slider-thumb { | |
| -webkit-appearance: none; | |
| width: 18px; | |
| height: 18px; | |
| border-radius: 50%; | |
| background: #fff; | |
| cursor: pointer; | |
| box-shadow: 0 2px 4px rgba(0,0,0,0.3); | |
| } | |
| .pulse-animation { | |
| animation: pulse-glow 2s infinite; | |
| } | |
| @keyframes pulse-glow { | |
| 0% { box-shadow: 0 0 5px rgba(139, 92, 246, 0.5); } | |
| 50% { box-shadow: 0 0 20px rgba(139, 92, 246, 0.8); } | |
| 100% { box-shadow: 0 0 5px rgba(139, 92, 246, 0.5); } | |
| } | |
| </style> | |
| </head> | |
| <body class="bg-gray-900 text-white overflow-hidden"> | |
| <!-- Header --> | |
| <header class="absolute top-0 left-0 right-0 z-50 p-4 flex justify-between items-center bg-gray-900 bg-opacity-80 backdrop-blur-sm"> | |
| <div class="flex items-center space-x-3"> | |
| <div class="p-2 rounded-lg bg-gradient-to-r from-purple-500 to-indigo-600"> | |
| <i data-feather="music" class="text-white"></i> | |
| </div> | |
| <h1 class="text-3xl font-bold bg-clip-text text-transparent bg-gradient-to-r from-purple-400 via-pink-400 to-indigo-300"> | |
| Sonic Canvas | |
| </h1> | |
| </div> | |
| <div class="flex space-x-3"> | |
| <button id="playBtn" class="px-5 py-2 btn-primary rounded-lg flex items-center font-medium"> | |
| <i data-feather="play" class="mr-2"></i> Play | |
| </button> | |
| <button id="clearBtn" class="px-5 py-2 btn-secondary rounded-lg flex items-center font-medium"> | |
| <i data-feather="trash-2" class="mr-2"></i> Clear | |
| </button> | |
| </div> | |
| </header> | |
| <!-- Main Grid --> | |
| <div id="grid-container" class="grid grid-cols-16 grid-rows-12 h-screen w-screen p-6 pt-24 pb-40"> | |
| <!-- Grid will be generated by JS --> | |
| </div> | |
| <!-- Controls --> | |
| <div class="absolute bottom-0 left-0 right-0 control-panel p-5"> | |
| <div class="max-w-7xl mx-auto"> | |
| <div class="flex flex-col lg:flex-row justify-between items-center space-y-4 lg:space-y-0"> | |
| <div class="flex items-center space-x-6"> | |
| <div class="flex items-center space-x-3"> | |
| <i data-feather="clock" class="text-indigo-400"></i> | |
| <label class="flex items-center"> | |
| <span class="mr-3 text-sm">Tempo:</span> | |
| <input type="range" id="tempo" min="40" max="240" value="120" class="slider w-32"> | |
| <span id="tempo-value" class="ml-3 text-sm font-mono bg-gray-700 px-2 py-1 rounded">120 BPM</span> | |
| </label> | |
| </div> | |
| <div class="flex items-center space-x-3"> | |
| <i data-feather="sliders" class="text-indigo-400"></i> | |
| <span class="text-sm">Volume:</span> | |
| <input type="range" id="volume" min="0" max="100" value="70" class="slider w-24"> | |
| <span id="volume-value" class="text-sm font-mono bg-gray-700 px-2 py-1 rounded">70%</span> | |
| </div> | |
| </div> | |
| <div class="flex space-x-4"> | |
| <div class="flex items-center space-x-2 bg-gray-700 rounded-lg px-3 py-2"> | |
| <button id="octave-down" class="p-1 hover:bg-gray-600 rounded"> | |
| <i data-feather="minus" class="w-4 h-4"></i> | |
| </button> | |
| <span id="octave-display" class="text-sm font-medium px-2">Octave 4</span> | |
| <button id="octave-up" class="p-1 hover:bg-gray-600 rounded"> | |
| <i data-feather="plus" class="w-4 h-4"></i> | |
| </button> | |
| </div> | |
| <div class="flex items-center space-x-2 bg-gray-700 rounded-lg px-3 py-2"> | |
| <span class="text-sm">Scale:</span> | |
| <select id="scale-select" class="bg-gray-600 rounded px-2 py-1 text-sm"> | |
| <option value="major">Major</option> | |
| <option value="minor">Minor</option> | |
| <option value="pentatonic">Pentatonic</option> | |
| <option value="blues">Blues</option> | |
| <option value="dorian">Dorian</option> | |
| <option value="mixolydian">Mixolydian</option> | |
| </select> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- Virtual Keyboard --> | |
| <div id="keyboard-container" class="mt-6"> | |
| <div class="flex justify-between items-center mb-3"> | |
| <h3 class="text-lg font-semibold flex items-center"> | |
| <i data-feather="keyboard" class="mr-2 text-indigo-400"></i> | |
| Virtual Keyboard | |
| </h3> | |
| <div class="text-sm text-gray-400"> | |
| Click or press keys A-S-D-F-G-H-J-K-L-; (white keys) and W-E-T-Y-U-O-P (black keys) | |
| </div> | |
| </div> | |
| <div id="keyboard" class="keyboard-container"> | |
| <!-- Keyboard will be generated by JS --> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <script src="https://cdn.jsdelivr.net/npm/feather-icons/dist/feather.min.js"></script> | |
| <script> | |
| // Initialize Feather Icons | |
| feather.replace(); | |
| // Initialize Vanta.js background | |
| VANTA.WAVES({ | |
| el: "#grid-container", | |
| mouseControls: true, | |
| touchControls: true, | |
| gyroControls: false, | |
| minHeight: 200.00, | |
| minWidth: 200.00, | |
| scale: 1.00, | |
| scaleMobile: 1.00, | |
| color: 0x1a1a2e, | |
| shininess: 15.00, | |
| waveHeight: 10.00, | |
| waveSpeed: 0.50 | |
| }); | |
| // Audio context | |
| const AudioContext = window.AudioContext || window.webkitAudioContext; | |
| const audioCtx = new AudioContext(); | |
| // Global variables | |
| let masterGain = audioCtx.createGain(); | |
| masterGain.connect(audioCtx.destination); | |
| masterGain.gain.value = 0.7; | |
| // Grid configuration | |
| const rows = 12; | |
| const cols = 16; | |
| const gridContainer = document.getElementById('grid-container'); | |
| let grid = Array(rows).fill().map(() => Array(cols).fill(0)); | |
| let isPlaying = false; | |
| let tempo = 120; | |
| let volume = 70; | |
| let currentStep = 0; | |
| let octave = 4; | |
| let scale = 'major'; | |
| let schedulerInterval; | |
| // Note frequencies (C4 = 261.63 Hz) | |
| const noteFrequencies = { | |
| 'C': 261.63, | |
| 'C#': 277.18, | |
| 'D': 293.66, | |
| 'D#': 311.13, | |
| 'E': 329.63, | |
| 'F': 349.23, | |
| 'F#': 369.99, | |
| 'G': 392.00, | |
| 'G#': 415.30, | |
| 'A': 440.00, | |
| 'A#': 466.16, | |
| 'B': 493.88 | |
| }; | |
| // Scales | |
| const scales = { | |
| major: [0, 2, 4, 5, 7, 9, 11], | |
| minor: [0, 2, 3, 5, 7, 8, 10], | |
| pentatonic: [0, 2, 4, 7, 9], | |
| blues: [0, 3, 5, 6, 7, 10], | |
| dorian: [0, 2, 3, 5, 7, 9, 10], | |
| mixolydian: [0, 2, 4, 5, 7, 9, 10] | |
| }; | |
| // Keyboard mapping | |
| const keyMap = { | |
| // White keys | |
| 'a': 'C', | |
| 's': 'D', | |
| 'd': 'E', | |
| 'f': 'F', | |
| 'g': 'G', | |
| 'h': 'A', | |
| 'j': 'B', | |
| // Black keys | |
| 'w': 'C#', | |
| 'e': 'D#', | |
| 't': 'F#', | |
| 'y': 'G#', | |
| 'u': 'A#' | |
| }; | |
| // Create grid | |
| function createGrid() { | |
| gridContainer.innerHTML = ''; | |
| for (let row = 0; row < rows; row++) { | |
| for (let col = 0; col < cols; col++) { | |
| const cell = document.createElement('div'); | |
| cell.className = `note-cell border border-gray-700 rounded-lg relative flex items-center justify-center`; | |
| cell.dataset.row = row; | |
| cell.dataset.col = col; | |
| // Add note label | |
| const noteLabel = document.createElement('span'); | |
| noteLabel.className = 'absolute top-1 left-1 text-xs opacity-70 font-medium'; | |
| noteLabel.textContent = getNoteName(row); | |
| cell.appendChild(noteLabel); | |
| // Add step indicator | |
| if (row === 0) { | |
| const stepLabel = document.createElement('span'); | |
| stepLabel.className = 'absolute bottom-1 right-1 text-xs opacity-70 font-medium'; | |
| stepLabel.textContent = col + 1; | |
| cell.appendChild(stepLabel); | |
| } | |
| // Add glow effect element | |
| const glow = document.createElement('div'); | |
| glow.className = 'absolute inset-0 rounded-lg opacity-0 bg-gradient-to-br from-purple-500 to-indigo-600'; | |
| cell.appendChild(glow); | |
| cell.addEventListener('click', () => toggleNote(row, col)); | |
| gridContainer.appendChild(cell); | |
| } | |
| } | |
| updateGridDisplay(); | |
| } | |
| // Get note name based on row and scale | |
| function getNoteName(row) { | |
| const scaleNotes = scales[scale]; | |
| const noteIndex = scaleNotes[11 - row] || 0; | |
| const noteNames = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B']; | |
| return noteNames[noteIndex]; | |
| } | |
| // Toggle note on/off | |
| function toggleNote(row, col) { | |
| grid[row][col] = grid[row][col] ? 0 : 1; | |
| updateGridDisplay(); | |
| // Visual feedback | |
| const cell = document.querySelector(`.note-cell[data-row="${row}"][data-col="${col}"]`); | |
| cell.classList.add('pulse-animation'); | |
| setTimeout(() => cell.classList.remove('pulse-animation'), 200); | |
| } | |
| // Update grid display | |
| function updateGridDisplay() { | |
| const cells = document.querySelectorAll('.note-cell'); | |
| cells.forEach(cell => { | |
| const row = parseInt(cell.dataset.row); | |
| const col = parseInt(cell.dataset.col); | |
| if (grid[row][col]) { | |
| cell.classList.add('bg-gradient-to-br', 'from-purple-600', 'to-indigo-700'); | |
| cell.classList.remove('bg-gray-800'); | |
| } else { | |
| cell.classList.remove('bg-gradient-to-br', 'from-purple-600', 'to-indigo-700'); | |
| cell.classList.add('bg-gray-800'); | |
| } | |
| }); | |
| } | |
| // Play a note | |
| function playNote(frequency, duration = 0.5, volumeLevel = 1) { | |
| const oscillator = audioCtx.createOscillator(); | |
| const gainNode = audioCtx.createGain(); | |
| oscillator.connect(gainNode); | |
| gainNode.connect(masterGain); | |
| oscillator.type = 'sine'; | |
| oscillator.frequency.value = frequency; | |
| // Apply volume setting | |
| const adjustedVolume = (volume / 100) * volumeLevel; | |
| gainNode.gain.setValueAtTime(adjustedVolume, audioCtx.currentTime); | |
| gainNode.gain.exponentialRampToValueAtTime(0.01, audioCtx.currentTime + duration); | |
| oscillator.start(); | |
| oscillator.stop(audioCtx.currentTime + duration); | |
| } | |
| // Play sequence | |
| function playSequence() { | |
| if (!isPlaying) return; | |
| // Remove previous column highlight | |
| document.querySelectorAll('.note-cell').forEach(cell => { | |
| cell.classList.remove('playing-column'); | |
| }); | |
| // Highlight current column | |
| document.querySelectorAll(`.note-cell[data-col="${currentStep}"]`).forEach(cell => { | |
| cell.classList.add('playing-column'); | |
| }); | |
| // Play notes in current column | |
| for (let row = 0; row < rows; row++) { | |
| if (grid[row][currentStep]) { | |
| const scaleNotes = scales[scale]; | |
| const noteIndex = scaleNotes[11 - row] || 0; | |
| const noteNames = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B']; | |
| const noteName = noteNames[noteIndex]; | |
| const frequency = noteFrequencies[noteName] * Math.pow(2, octave - 4); | |
| playNote(frequency, (60 / tempo) * 0.8); | |
| // Add visual feedback | |
| const cell = document.querySelector(`.note-cell[data-row="${row}"][data-col="${currentStep}"]`); | |
| cell.classList.add('active-note'); | |
| setTimeout(() => cell.classList.remove('active-note'), 300); | |
| } | |
| } | |
| currentStep = (currentStep + 1) % cols; | |
| } | |
| // Scheduler for precise timing | |
| function schedulePlayback() { | |
| clearInterval(schedulerInterval); | |
| const interval = (60 / tempo) * 1000; | |
| schedulerInterval = setInterval(playSequence, interval); | |
| } | |
| // Create virtual keyboard | |
| function createKeyboard() { | |
| const keyboard = document.getElementById('keyboard'); | |
| keyboard.innerHTML = ''; | |
| const whiteKeys = ['C', 'D', 'E', 'F', 'G', 'A', 'B']; | |
| const blackKeys = { | |
| 1: 'C#', | |
| 2: 'D#', | |
| 4: 'F#', | |
| 5: 'G#', | |
| 6: 'A#' | |
| }; | |
| // Create white keys | |
| whiteKeys.forEach((note, i) => { | |
| const key = document.createElement('div'); | |
| key.className = 'white-key'; | |
| key.dataset.note = note; | |
| key.dataset.key = Object.keys(keyMap).find(key => keyMap[key] === note); | |
| key.innerHTML = `<span>${note}<br><small>${key.dataset.key || ''}</small></span>`; | |
| key.addEventListener('mousedown', () => playKeyNote(note)); | |
| keyboard.appendChild(key); | |
| // Add black key if needed | |
| if (blackKeys[i+1]) { | |
| const blackKey = document.createElement('div'); | |
| blackKey.className = 'black-key'; | |
| blackKey.dataset.note = blackKeys[i+1]; | |
| blackKey.dataset.key = Object.keys(keyMap).find(key => keyMap[key] === blackKeys[i+1]); | |
| blackKey.innerHTML = `<span>${blackKeys[i+1]}<br><small>${blackKey.dataset.key || ''}</small></span>`; | |
| blackKey.style.left = `${(i * 50) + 35}px`; | |
| blackKey.addEventListener('mousedown', () => playKeyNote(blackKeys[i+1])); | |
| keyboard.appendChild(blackKey); | |
| } | |
| }); | |
| } | |
| // Play note from keyboard | |
| function playKeyNote(note) { | |
| const frequency = noteFrequencies[note] * Math.pow(2, octave - 4); | |
| playNote(frequency, 0.5, 0.8); | |
| // Visual feedback | |
| const key = document.querySelector(`.white-key[data-note="${note}"], .black-key[data-note="${note}"]`); | |
| if (key) { | |
| key.classList.add('active'); | |
| setTimeout(() => key.classList.remove('active'), 200); | |
| } | |
| } | |
| // Handle keyboard events | |
| function handleKeyDown(e) { | |
| const key = e.key.toLowerCase(); | |
| if (keyMap[key]) { | |
| playKeyNote(keyMap[key]); | |
| // Visual feedback | |
| const keyElement = document.querySelector(`.white-key[data-key="${key}"], .black-key[data-key="${key}"]`); | |
| if (keyElement) { | |
| keyElement.classList.add('active'); | |
| } | |
| } | |
| } | |
| function handleKeyUp(e) { | |
| const key = e.key.toLowerCase(); | |
| if (keyMap[key]) { | |
| const keyElement = document.querySelector(`.white-key[data-key="${key}"], .black-key[data-key="${key}"]`); | |
| if (keyElement) { | |
| keyElement.classList.remove('active'); | |
| } | |
| } | |
| } | |
| // Event listeners | |
| document.getElementById('playBtn').addEventListener('click', () => { | |
| // Resume audio context on first interaction | |
| if (audioCtx.state === 'suspended') { | |
| audioCtx.resume(); | |
| } | |
| isPlaying = !isPlaying; | |
| document.getElementById('playBtn').innerHTML = isPlaying ? | |
| '<i data-feather="pause" class="mr-2"></i> Pause' : | |
| '<i data-feather="play" class="mr-2"></i> Play'; | |
| feather.replace(); | |
| if (isPlaying) { | |
| currentStep = 0; | |
| schedulePlayback(); | |
| } else { | |
| clearInterval(schedulerInterval); | |
| // Remove column highlights | |
| document.querySelectorAll('.note-cell').forEach(cell => { | |
| cell.classList.remove('playing-column'); | |
| }); | |
| } | |
| }); | |
| document.getElementById('clearBtn').addEventListener('click', () => { | |
| grid = Array(rows).fill().map(() => Array(cols).fill(0)); | |
| updateGridDisplay(); | |
| }); | |
| document.getElementById('tempo').addEventListener('input', (e) => { | |
| tempo = e.target.value; | |
| document.getElementById('tempo-value').textContent = `${tempo} BPM`; | |
| if (isPlaying) { | |
| schedulePlayback(); | |
| } | |
| }); | |
| document.getElementById('volume').addEventListener('input', (e) => { | |
| volume = e.target.value; | |
| document.getElementById('volume-value').textContent = `${volume}%`; | |
| masterGain.gain.value = volume / 100; | |
| }); | |
| document.getElementById('octave-down').addEventListener('click', () => { | |
| if (octave > 2) { | |
| octave--; | |
| document.getElementById('octave-display').textContent = `Octave ${octave}`; | |
| createKeyboard(); | |
| } | |
| }); | |
| document.getElementById('octave-up').addEventListener('click', () => { | |
| if (octave < 6) { | |
| octave++; | |
| document.getElementById('octave-display').textContent = `Octave ${octave}`; | |
| createKeyboard(); | |
| } | |
| }); | |
| document.getElementById('scale-select').addEventListener('change', (e) => { | |
| scale = e.target.value; | |
| createGrid(); | |
| createKeyboard(); | |
| }); | |
| // Keyboard event listeners | |
| document.addEventListener('keydown', handleKeyDown); | |
| document.addEventListener('keyup', handleKeyUp); | |
| // Initialize | |
| createGrid(); | |
| createKeyboard(); | |
| feather.replace(); | |
| </script> | |
| </body> | |
| </html> | |