sonic-canvas / index.html
lonestar108's picture
enhance it, fix the keys offset rendering issue, improve everything
1bbafd0 verified
<!DOCTYPE html>
<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) !important;
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>