Show description
AI Virtual Piano
AI Virtual Piano
Choose a song:
None
Twinkle Twinkle Little Star
Mary Had a Little Lamb
Ode to Joy
Jingle Bells
Happy Birthday
Generate Song
AI Virtual Piano
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>AI Virtual Piano</title>
<script src="https://cdn.tailwindcss.com"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/tone/14.7.77/Tone.js"></script>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;700&display=swap" rel="stylesheet">
<style>
/* --- General Body & Layout --- */
body {
font-family: 'Inter', sans-serif;
overflow: hidden;
display: flex;
justify-content: center;
}
main {
width: 100%;
max-width: 1024px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: flex-end;
height: 100vh;
}
/* --- Piano Keyboard --- */
.piano-container {
display: flex;
position: relative;
height: 200px;
margin-bottom: 20px;
background: #222;
padding: 10px;
border-radius: 10px;
box-shadow: 0 10px 25px -5px rgba(0, 0, 0, 0.2), 0 8px 10px -6px rgba(0, 0, 0, 0.1);
}
.key {
border: 1px solid #4a4a4a;
box-sizing: border-box;
cursor: pointer;
transition: background-color 0.1s ease;
display: flex;
justify-content: center;
align-items: flex-end;
padding-bottom: 8px;
font-weight: 500;
border-radius: 0 0 5px 5px;
position: relative;
}
.white-key {
width: 60px;
height: 100%;
background-color: white;
z-index: 1;
color: #333;
}
.black-key {
width: 38px;
height: 60%;
background-color: #333;
position: absolute;
z-index: 2;
color: white;
border-width: 1px 2px 5px;
border-color: #555;
border-radius: 0 0 4px 4px;
}
.key.active {
background-color: #60a5fa;
border-color: #2563eb;
}
/* --- Colored Note Labels --- */
.note-label {
width: 28px;
height: 28px;
border-radius: 50%;
display: flex;
justify-content: center;
align-items: center;
font-weight: bold;
font-size: 16px;
position: absolute;
top: 10px;
left: 50%;
transform: translateX(-50%);
color: #1f2937;
box-shadow: 0 1px 3px rgba(0,0,0,0.2);
border: 1px solid rgba(0,0,0,0.1);
user-select: none;
}
.note-label-C { background-color: #fef08a; } .black-key .note-label-C { background-color: #eab308; }
.note-label-D { background-color: #fdba74; } .black-key .note-label-D { background-color: #f97316; }
.note-label-E { background-color: #fca5a5; }
.note-label-F { background-color: #86efac; } .black-key .note-label-F { background-color: #22c55e; }
.note-label-G { background-color: #93c5fd; } .black-key .note-label-G { background-color: #3b82f6; }
.note-label-A { background-color: #d8b4fe; } .black-key .note-label-A { background-color: #a855f7; }
.note-label-B { background-color: #f9a8d4; }
.black-key .note-label { color: #f9fafb; border-color: rgba(255,255,255,0.2); }
/* --- Chord Legend Widget --- */
#chord-legend {
position: fixed;
right: 20px;
top: 50%;
transform: translateY(-50%);
width: 280px; /* Increased default width */
min-width: 220px;
min-height: 200px;
background-color: #111827;
color: #d1d5db;
border: 2px solid #000;
border-radius: 12px;
box-shadow: 0 10px 20px rgba(0,0,0,0.3);
z-index: 100;
display: flex;
flex-direction: column;
}
#chord-legend-header {
padding: 8px 16px;
cursor: move;
background-color: #1f2937;
border-bottom: 1px solid #374151;
border-radius: 10px 10px 0 0;
user-select: none;
}
.chord-list {
flex-grow: 1; /* Allow list to fill space */
overflow-y: auto;
padding: 16px;
}
.chord-item { margin-bottom: 12px; }
.chord-name { font-weight: bold; color: #fff; font-size: 1.1rem; }
.chord-keys { font-family: monospace; background-color: #374151; padding: 4px 8px; border-radius: 6px; display: inline-block; margin-top: 4px; }
#resize-handle {
width: 20px;
height: 20px;
position: absolute;
bottom: 0;
right: 0;
cursor: nwse-resize;
}
/* Themed Scrollbar */
.chord-list::-webkit-scrollbar { width: 8px; }
.chord-list::-webkit-scrollbar-track { background: #1f2937; border-radius: 4px;}
.chord-list::-webkit-scrollbar-thumb { background: #4b5563; border-radius: 4px; }
.chord-list::-webkit-scrollbar-thumb:hover { background: #6b7280; }
/* --- Note Falling Animation --- */
.falling-note-area { width: 100%; flex-grow: 1; position: relative; }
.falling-note-container { position: absolute; top: 0; left: 0; width: 100%; height: 100%; pointer-events: none; }
.falling-note { position: absolute; background-color: #3b82f6; border-radius: 4px; width: 58px; height: 20px; }
.black-key-note { background-color: #1e40af; width: 36px; }
.play-line { position: absolute; bottom: 0; width: 100%; height: 2px; background-color: #ef4444; z-index: 5; }
/* --- UI Controls & Loader --- */
.controls { padding: 20px; }
.loader { border: 4px solid #f3f3f3; border-radius: 50%; border-top: 4px solid #3498db; width: 24px; height: 24px; animation: spin 1.5s linear infinite; }
@keyframes spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } }
</style>
</head>
<body class="bg-gray-100 h-screen">
<main>
<div id="falling-note-area" class="falling-note-area">
<div class="falling-note-container"></div>
<div class="play-line"></div>
</div>
<div class="piano-container" id="piano"></div>
<div class="controls flex flex-col items-center space-y-4">
<div class="flex items-center space-x-4">
<label for="song-select" class="text-lg font-medium text-gray-700">Choose a song:</label>
<select id="song-select" class="p-2 rounded-lg border-2 border-gray-300 focus:border-blue-500 focus:ring focus:ring-blue-200">
<option value="none">None</option>
<option value="twinkle">Twinkle Twinkle Little Star</option>
<option value="mary">Mary Had a Little Lamb</option>
<option value="ode">Ode to Joy</option>
<option value="jingle">Jingle Bells</option>
<option value="happy_birthday">Happy Birthday</option>
</select>
</div>
<div class="flex items-center space-x-2">
<input type="text" id="song-input" placeholder="Or type a song name here..." class="p-2 w-64 rounded-lg border-2 border-gray-300 focus:border-blue-500 focus:ring focus:ring-blue-200">
<button id="generate-btn" class="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded-lg flex items-center justify-center w-40">
<span id="generate-btn-text">Generate Song</span>
<div id="loader" class="loader hidden ml-2"></div>
</button>
</div>
<div id="error-message" class="text-red-500 font-medium h-5"></div>
</div>
</main>
<div id="chord-legend"></div>
<script>
document.addEventListener('DOMContentLoaded', () => {
const piano = document.getElementById('piano');
const songSelect = document.getElementById('song-select');
const fallingNoteContainer = document.querySelector('.falling-note-container');
const generateBtn = document.getElementById('generate-btn');
const generateBtnText = document.getElementById('generate-btn-text');
const songInput = document.getElementById('song-input');
const loader = document.getElementById('loader');
const errorMessage = document.getElementById('error-message');
const chordLegend = document.getElementById('chord-legend');
const synth = new Tone.PolySynth(Tone.Synth).toDestination();
const notes = [
{ note: 'C4', key: 'a' }, { note: 'C#4', key: 'w' }, { note: 'D4', key: 's' }, { note: 'D#4', key: 'e' },
{ note: 'E4', key: 'd' }, { note: 'F4', key: 'f' }, { note: 'F#4', key: 't' }, { note: 'G4', key: 'g' },
{ note: 'G#4', key: 'y' }, { note: 'A4', key: 'h' }, { note: 'A#4', key: 'u' }, { note: 'B4', key: 'j' },
{ note: 'C5', key: 'k' }, { note: 'C#5', key: 'o' }, { note: 'D5', key: 'l' }, { note: 'D#5', key: 'p' },
{ note: 'E5', key: ';' }
];
const keyMap = {};
const noteToKeyMap = {};
notes.forEach(({ note, key }) => { keyMap[key] = note; noteToKeyMap[note] = key; });
const noteColorMap = {
'C': 'note-label-C', 'D': 'note-label-D', 'E': 'note-label-E',
'F': 'note-label-F', 'G': 'note-label-G', 'A': 'note-label-A', 'B': 'note-label-B'
};
// --- Create Piano Keys ---
const keyElements = {};
let whiteKeyCount = 0;
notes.forEach(({ note, key }) => {
if (!note.includes('#')) {
const keyElement = document.createElement('div');
keyElement.className = 'key white-key';
keyElement.dataset.note = note;
keyElement.innerHTML = `<span>${key.toUpperCase()}</span>`;
const noteName = note.charAt(0);
const colorClass = noteColorMap[noteName];
if (colorClass) {
const noteLabel = document.createElement('div');
noteLabel.className = `note-label ${colorClass}`;
noteLabel.textContent = noteName;
keyElement.appendChild(noteLabel);
}
piano.appendChild(keyElement);
keyElements[note] = keyElement;
whiteKeyCount++;
}
});
const whiteKeyWidth = 60;
piano.style.width = `${whiteKeyCount * whiteKeyWidth}px`;
let whiteKeyIndex = 0;
notes.forEach(({ note, key }) => {
if (note.includes('#')) {
const keyElement = document.createElement('div');
keyElement.className = 'key black-key';
keyElement.dataset.note = note;
keyElement.innerHTML = `<span>${key.toUpperCase()}</span>`;
keyElement.style.left = `${(whiteKeyIndex * whiteKeyWidth) - 19}px`;
const noteName = note.charAt(0);
const colorClass = noteColorMap[noteName];
if (colorClass) {
const noteLabel = document.createElement('div');
noteLabel.className = `note-label ${colorClass}`;
noteLabel.textContent = noteName;
keyElement.appendChild(noteLabel);
}
piano.appendChild(keyElement);
keyElements[note] = keyElement;
} else {
whiteKeyIndex++;
}
});
// --- Chord Data and Legend Population ---
const chords = [
{ name: 'C', major: ['C4', 'E4', 'G4'], minor: ['C4', 'D#4', 'G4'] },
{ name: 'C#', major: ['C#4', 'F4', 'G#4'], minor: ['C#4', 'E4', 'G#4'] },
{ name: 'D', major: ['D4', 'F#4', 'A4'], minor: ['D4', 'F4', 'A4'] },
{ name: 'D#', major: ['D#4', 'G4', 'A#4'], minor: ['D#4', 'F#4', 'A#4'] },
{ name: 'E', major: ['E4', 'G#4', 'B4'], minor: ['E4', 'G4', 'B4'] },
{ name: 'F', major: ['F4', 'A4', 'C5'], minor: ['F4', 'G#4', 'C5'] },
{ name: 'F#', major: ['F#4', 'A#4', 'C#5'], minor: ['F#4', 'A4', 'C#5'] },
{ name: 'G', major: ['G4', 'B4', 'D5'], minor: ['G4', 'A#4', 'D5'] },
{ name: 'G#', major: ['G#4', 'C5', 'D#5'], minor: ['G#4', 'B4', 'D#5'] },
{ name: 'A', major: ['A4', 'C#5', 'E5'], minor: ['A4', 'C5', 'E5'] },
{ name: 'A#', minor: ['A#4', 'C#5', 'E5'] }, // Major not available on this keyboard
{ name: 'B', minor: ['B4', 'D5', 'F#4'] }, // Major not available on this keyboard
];
function populateChordLegend() {
let legendHTML = `
<div id="chord-legend-header">
<h2 class="text-xl font-bold text-white">Chord Legend</h2>
</div>
<div class="chord-list">
`;
chords.forEach(chord => {
const majorKeys = chord.major?.map(n => noteToKeyMap[n]?.toUpperCase()).filter(Boolean).join(' + ');
const minorKeys = chord.minor?.map(n => noteToKeyMap[n]?.toUpperCase()).filter(Boolean).join(' + ');
legendHTML += `<div class="chord-item"><div class="chord-name">${chord.name}</div>`;
if (majorKeys) legendHTML += `<div>Major: <span class="chord-keys">${majorKeys}</span></div>`;
if (minorKeys) legendHTML += `<div class="mt-1">Minor: <span class="chord-keys">${minorKeys}</span></div>`;
legendHTML += `</div>`;
});
legendHTML += '</div><div id="resize-handle"></div>';
chordLegend.innerHTML = legendHTML;
makeDraggableAndResizable();
}
populateChordLegend();
// --- Draggable and Resizable Logic ---
function makeDraggableAndResizable() {
const header = document.getElementById('chord-legend-header');
const resizeHandle = document.getElementById('resize-handle');
let isDragging = false, isResizing = false;
let offsetX, offsetY, startX, startY, startWidth, startHeight;
header.addEventListener('mousedown', (e) => {
isDragging = true;
offsetX = e.clientX - chordLegend.offsetLeft;
offsetY = e.clientY - chordLegend.offsetTop;
chordLegend.style.transition = 'none';
});
resizeHandle.addEventListener('mousedown', (e) => {
e.preventDefault();
isResizing = true;
startX = e.clientX;
startY = e.clientY;
startWidth = parseInt(document.defaultView.getComputedStyle(chordLegend).width, 10);
startHeight = parseInt(document.defaultView.getComputedStyle(chordLegend).height, 10);
});
document.addEventListener('mousemove', (e) => {
if (isDragging) {
chordLegend.style.left = `${e.clientX - offsetX}px`;
chordLegend.style.top = `${e.clientY - offsetY}px`;
}
if (isResizing) {
const newWidth = startWidth + e.clientX - startX;
const newHeight = startHeight + e.clientY - startY;
chordLegend.style.width = `${newWidth}px`;
chordLegend.style.height = `${newHeight}px`;
}
});
document.addEventListener('mouseup', () => {
isDragging = false;
isResizing = false;
chordLegend.style.transition = '';
});
}
// --- Song Data ---
const songs = {
twinkle: [ { note: 'C4', time: 0 }, { note: 'C4', time: 0.5 }, { note: 'G4', time: 1 }, { note: 'G4', time: 1.5 }, { note: 'A4', time: 2 }, { note: 'A4', time: 2.5 }, { note: 'G4', time: 3 }, { note: 'F4', time: 4 }, { note: 'F4', time: 4.5 }, { note: 'E4', time: 5 }, { note: 'E4', time: 5.5 }, { note: 'D4', time: 6 }, { note: 'D4', time: 6.5 }, { note: 'C4', time: 7 } ],
mary: [ { note: 'E4', time: 0 }, { note: 'D4', time: 0.5 }, { note: 'C4', time: 1 }, { note: 'D4', time: 1.5 }, { note: 'E4', time: 2 }, { note: 'E4', time: 2.5 }, { note: 'E4', time: 3 }, { note: 'D4', time: 4 }, { note: 'D4', time: 4.5 }, { note: 'D4', time: 5 }, { note: 'E4', time: 6 }, { note: 'G4', time: 6.5 }, { note: 'G4', time: 7 } ],
ode: [ { note: 'E4', time: 0 }, { note: 'E4', time: 0.5 }, { note: 'F4', time: 1 }, { note: 'G4', time: 1.5 }, { note: 'G4', time: 2 }, { note: 'F4', time: 2.5 }, { note: 'E4', time: 3 }, { note: 'D4', time: 3.5 }, { note: 'C4', time: 4 }, { note: 'C4', time: 4.5 }, { note: 'D4', time: 5 }, { note: 'E4', time: 5.5 }, { note: 'E4', time: 6.4 }, { note: 'D4', time: 7 }, { note: 'D4', time: 7.5 } ],
jingle: [ { note: 'E4', time: 0 }, { note: 'E4', time: 0.5 }, { note: 'E4', time: 1 }, { note: 'E4', time: 2 }, { note: 'E4', time: 2.5 }, { note: 'E4', time: 3 }, { note: 'E4', time: 4 }, { note: 'G4', time: 4.5 }, { note: 'C4', time: 5 }, { note: 'D4', time: 5.5 }, { note: 'E4', time: 6 } ],
happy_birthday: [ { note: 'C4', time: 0 }, { note: 'C4', time: 0.5 }, { note: 'D4', time: 1 }, { note: 'C4', time: 1.5 }, { note: 'F4', time: 2 }, { note: 'E4', time: 2.5 }, { note: 'C4', time: 4 }, { note: 'C4', time: 4.5 }, { note: 'D4', time: 5 }, { note: 'C4', time: 5.5 }, { note: 'G4', time: 6 }, { note: 'F4', time: 6.5 } ]
};
let currentSong = null;
let startTime = 0;
let animationFrameId = null;
const activeNotes = new Set();
// --- Note Playing Logic (Attack/Release) ---
function startNote(note) {
if (!note || !keyElements[note] || activeNotes.has(note)) return;
synth.triggerAttack(note);
activeNotes.add(note);
keyElements[note].classList.add('active');
}
function stopNote(note) {
if (!note || !activeNotes.has(note)) return;
synth.triggerRelease(note);
activeNotes.delete(note);
keyElements[note].classList.remove('active');
}
// --- Event Handlers for User Interaction ---
piano.addEventListener('mousedown', (e) => {
const key = e.target.closest('.key');
if (key) startNote(key.dataset.note);
});
piano.addEventListener('mouseup', (e) => {
const key = e.target.closest('.key');
if (key) stopNote(key.dataset.note);
});
piano.addEventListener('mouseleave', () => {
activeNotes.forEach(note => stopNote(note));
});
document.addEventListener('keydown', (e) => {
if (e.repeat || songInput === document.activeElement) return;
const note = keyMap[e.key];
if (note) startNote(note);
});
document.addEventListener('keyup', (e) => {
const note = keyMap[e.key];
if (note) stopNote(note);
});
songSelect.addEventListener('change', (e) => handleSongSelection(e.target.value));
function handleSongSelection(songName) {
if (animationFrameId) cancelAnimationFrame(animationFrameId);
fallingNoteContainer.innerHTML = '';
if (songName !== 'none' && songs[songName]) {
currentSong = songs[songName];
startSongAnimation();
} else {
currentSong = null;
}
}
// --- Animation Logic ---
function startSongAnimation() {
startTime = performance.now();
const pianoRect = piano.getBoundingClientRect();
const fallingAreaRect = document.getElementById('falling-note-area').getBoundingClientRect();
const pianoLeftOffset = pianoRect.left - fallingAreaRect.left;
currentSong.forEach(noteData => {
const keyElement = keyElements[noteData.note];
if (!keyElement) return;
const noteElement = document.createElement('div');
noteElement.className = 'falling-note';
if (keyElement.classList.contains('black-key')) {
noteElement.classList.add('black-key-note');
}
noteElement.style.left = `${keyElement.offsetLeft + pianoLeftOffset + 1}px`;
noteElement.dataset.time = noteData.time;
noteElement.dataset.note = noteData.note;
noteElement.dataset.played = 'false';
fallingNoteContainer.appendChild(noteElement);
});
animateFallingNotes();
}
function animateFallingNotes() {
const currentTime = (performance.now() - startTime) / 1000;
const fallSpeed = 150;
const notesToAnimate = fallingNoteContainer.querySelectorAll('.falling-note');
let remainingNotes = false;
notesToAnimate.forEach(noteElement => {
const noteTime = parseFloat(noteElement.dataset.time);
const timeDifference = noteTime - currentTime;
if (timeDifference > -5) {
remainingNotes = true;
const yPos = fallingNoteContainer.clientHeight - (timeDifference * fallSpeed);
noteElement.style.transform = `translateY(${yPos}px)`;
if (yPos >= fallingNoteContainer.clientHeight && noteElement.dataset.played === 'false') {
const noteToPlay = noteElement.dataset.note;
synth.triggerAttackRelease(noteToPlay, '8n', Tone.now());
const keyEl = keyElements[noteToPlay];
if(keyEl) {
keyEl.classList.add('active');
setTimeout(() => {
if(!activeNotes.has(noteToPlay)) keyEl.classList.remove('active');
}, 200);
}
noteElement.dataset.played = 'true';
noteElement.style.opacity = '0.3';
}
} else {
if (noteElement.parentElement) noteElement.parentElement.removeChild(noteElement);
}
});
if (remainingNotes) animationFrameId = requestAnimationFrame(animateFallingNotes);
}
// --- AI Song Generation ---
generateBtn.addEventListener('click', async () => {
const songTitle = songInput.value.trim();
if (!songTitle) { errorMessage.textContent = 'Please enter a song title.'; return; }
errorMessage.textContent = '';
loader.classList.remove('hidden');
generateBtnText.textContent = '';
generateBtn.disabled = true;
songInput.disabled = true;
try {
const availableNotes = notes.map(n => n.note).join(', ');
const prompt = `Generate the melody for "${songTitle}" as a JSON array. Each object needs "note" (from: ${availableNotes}) and "time" (seconds). Example: [{"note": "C4", "time": 0}, ...]. Output only the raw JSON array.`;
const payload = { contents: [{ role: "user", parts: [{ text: prompt }] }], generationConfig: { responseMimeType: "application/json", responseSchema: { type: "ARRAY", items: { type: "OBJECT", properties: { "note": { "type": "STRING" }, "time": { "type": "NUMBER" } }, required: ["note", "time"] } } } };
const apiKey = "";
const apiUrl = `https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash-preview-05-20:generateContent?key=${apiKey}`;
const response = await fetch(apiUrl, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) });
if (!response.ok) throw new Error(`API error: ${response.statusText}`);
const result = await response.json();
if (result.candidates?.[0]?.content?.parts?.[0]) {
const generatedSong = JSON.parse(result.candidates[0].content.parts[0].text);
const songId = songTitle.toLowerCase().replace(/\s+/g, '_');
songs[songId] = generatedSong;
const option = document.createElement('option');
option.value = songId;
option.textContent = songTitle;
songSelect.appendChild(option);
songSelect.value = songId;
handleSongSelection(songId);
songInput.value = '';
} else {
throw new Error("AI did not return a valid song structure.");
}
} catch (error) {
console.error('Error generating song:', error);
errorMessage.textContent = 'Could not generate song. Please try again.';
} finally {
loader.classList.add('hidden');
generateBtnText.textContent = 'Generate Song';
generateBtn.disabled = false;
songInput.disabled = false;
}
});
});
</script>
</body>
</html>