Music
Virtual Piano
Play piano in your browser. Use keyboard shortcuts or click keys. 15 practice melodies with auto-play.
Play a note…
OCT ±0
[ lower ] higher
Keyboard Map
Tips: Z–M = lower octave · Q–U = upper octave · S D G H J = black keys (lower) · 2 3 5 6 7 = black keys (upper) · I = top C ·
Hold Left Shift for staccato (short) · Hold Right Shift for sustain (long) · [ / ] to shift octave down / up (−1 to +2)
Developer Reference
Core Algorithm & Standalone Script
Standalone, zero-dependency JavaScript implementation powering this tool. Free to inspect, copy, and build upon.
// ── Audio ──────────────────────────────────────────────────────────
let audioCtx = null, masterGain = null;
function getCtx() {
if (!audioCtx) {
audioCtx = new (window.AudioContext || window.webkitAudioContext)();
masterGain = audioCtx.createGain();
masterGain.gain.value = 0.7;
masterGain.connect(audioCtx.destination);
}
if (audioCtx.state === 'suspended') audioCtx.resume();
return audioCtx;
}
function playFreq(freq, duration) {
const ctx = getCtx();
const t = ctx.currentTime;
const o1 = ctx.createOscillator(), g1 = ctx.createGain();
o1.type = 'triangle'; o1.frequency.value = freq;
g1.gain.setValueAtTime(0, t);
g1.gain.linearRampToValueAtTime(0.6, t + 0.006);
g1.gain.exponentialRampToValueAtTime(0.25, t + 0.1);
g1.gain.exponentialRampToValueAtTime(0.001, t + duration);
o1.connect(g1); g1.connect(masterGain); o1.start(t); o1.stop(t + duration);
const o2 = ctx.createOscillator(), g2 = ctx.createGain();
o2.type = 'sine'; o2.frequency.value = freq * 2;
g2.gain.setValueAtTime(0.14, t);
g2.gain.exponentialRampToValueAtTime(0.001, t + duration * 0.4);
o2.connect(g2); g2.connect(masterGain); o2.start(t); o2.stop(t + duration * 0.4);
const o3 = ctx.createOscillator(), g3 = ctx.createGain();
o3.type = 'sine'; o3.frequency.value = freq * 3;
g3.gain.setValueAtTime(0.07, t);
g3.gain.exponentialRampToValueAtTime(0.001, t + duration * 0.15);
o3.connect(g3); g3.connect(masterGain); o3.start(t); o3.stop(t + duration * 0.15);
}
// ── Note data ──────────────────────────────────────────────────────
const FREQS = {
'C3':130.81,'C#3':138.59,'D3':146.83,'D#3':155.56,'E3':164.81,
'F3':174.61,'F#3':185.00,'G3':196.00,'G#3':207.65,'A3':220.00,'A#3':233.08,'B3':246.94,
'C4':261.63,'C#4':277.18,'D4':293.66,'D#4':311.13,'E4':329.63,
'F4':349.23,'F#4':369.99,'G4':392.00,'G#4':415.30,'A4':440.00,'A#4':466.16,'B4':493.88,
'C5':523.25,'C#5':554.37,'D5':587.33,'D#5':622.25,'E5':659.25,
'F5':698.46,'F#5':739.99,'G5':783.99,'G#5':830.61,'A5':880.00,'A#5':932.33,'B5':987.77,
'C6':1046.50,'C#6':1108.73,'D6':1174.66,'D#6':1244.51,'E6':1318.51,
'F6':1396.91,'F#6':1479.98,'G6':1567.98,'G#6':1661.22,'A6':1760.00,'A#6':1864.66,'B6':1975.53,
'C7':2093.00
};
// aliases
FREQS['Db3']=FREQS['C#3']; FREQS['Eb3']=FREQS['D#3']; FREQS['Gb3']=FREQS['F#3'];
FREQS['Ab3']=FREQS['G#3']; FREQS['Bb3']=FREQS['A#3'];
FREQS['Db4']=FREQS['C#4']; FREQS['Eb4']=FREQS['D#4']; FREQS['Gb4']=FREQS['F#4'];
FREQS['Ab4']=FREQS['G#4']; FREQS['Bb4']=FREQS['A#4'];
FREQS['Db5']=FREQS['C#5']; FREQS['Eb5']=FREQS['D#5']; FREQS['Gb5']=FREQS['F#5'];
FREQS['Ab5']=FREQS['G#5']; FREQS['Bb5']=FREQS['A#5'];
FREQS['Db6']=FREQS['C#6']; FREQS['Eb6']=FREQS['D#6']; FREQS['Gb6']=FREQS['F#6'];
FREQS['Ab6']=FREQS['G#6']; FREQS['Bb6']=FREQS['A#6'];
const KEYMAP = {
'z':'C4','s':'C#4','x':'D4','d':'D#4','c':'E4',
'v':'F4','g':'F#4','b':'G4','h':'G#4','n':'A4','j':'A#4','m':'B4',
'q':'C5','2':'C#5','w':'D5','3':'D#5','e':'E5',
'r':'F5','5':'F#5','t':'G5','6':'G#5','y':'A5','7':'A#5','u':'B5',
'i':'C6'
};
const RMAP = Object.fromEntries(Object.entries(KEYMAP).map(([k,v])=>[v,k]));
// ── Piano layout: white keys in order, each with possible black key to right ──
// [noteName, blackKeyNote|null]
const WHITE_KEYS = [
['C3','C#3'],['D3','D#3'],['E3',null],
['F3','F#3'],['G3','G#3'],['A3','A#3'],['B3',null],
['C4','C#4'],['D4','D#4'],['E4',null],
['F4','F#4'],['G4','G#4'],['A4','A#4'],['B4',null],
['C5','C#5'],['D5','D#5'],['E5',null],
['F5','F#5'],['G5','G#5'],['A5','A#5'],['B5',null],
['C6','C#6'],['D6','D#6'],['E6',null],
['F6','F#6'],['G6','G#6'],['A6','A#6'],['B6',null],
['C7',null]
];
// ── Octave shift ───────────────────────────────────────────────────
let octaveOffset = 0;
const OCT_MIN = -1, OCT_MAX = 2;
function shiftedNote(baseNote) {
if (octaveOffset === 0) return baseNote;
const m = baseNote.match(/^([A-G]#?)(\d)$/);
if (!m) return baseNote;
return m[1] + (parseInt(m[2]) + octaveOffset);
}
function getActiveKeymap() {
const ak = {};
Object.entries(KEYMAP).forEach(([k, base]) => {
const n = shiftedNote(base);
if (FREQS[n]) ak[k] = n;
});
return ak;
}
// ── Build keyboard ─────────────────────────────────────────────────
function buildKeyboard() {
const kb = document.getElementById('keyboard');
const rmap = Object.fromEntries(Object.entries(getActiveKeymap()).map(([k,v])=>[v,k]));
WHITE_KEYS.forEach(([wNote, bNote]) => {
const wEl = document.createElement('div');
wEl.className = 'white-key';
wEl.dataset.note = wNote;
wEl.style.position = 'relative';
const kLabel = rmap[wNote] ? rmap[wNote].toUpperCase() : '';
wEl.innerHTML = `<span>${kLabel}</span>`;
if (bNote) {
const bEl = document.createElement('div');
bEl.className = 'black-key';
bEl.dataset.note = bNote;
bEl.style.left = 'calc(100% - 12px)';
bEl.innerHTML = rmap[bNote] ? `<span>${rmap[bNote].toUpperCase()}</span>` : '';
bEl.addEventListener('mousedown', e => { e.stopPropagation(); triggerNote(bNote); });
bEl.addEventListener('touchstart', e => { e.preventDefault(); e.stopPropagation(); triggerNote(bNote); }, {passive:false});
wEl.appendChild(bEl);
}
wEl.addEventListener('mousedown', () => triggerNote(wNote));
wEl.addEventListener('touchstart', e => { e.preventDefault(); triggerNote(wNote); }, {passive:false});
kb.appendChild(wEl);
});
}
let leftShift = false, rightShift = false;
function updateOctaveUI() {
const sign = octaveOffset > 0 ? '+' : '';
const label = octaveOffset === 0 ? 'OCT ±0' : `OCT ${sign}${octaveOffset}`;
document.getElementById('octaveBadge').textContent = label;
document.getElementById('keyboard').innerHTML = '';
buildKeyboard();
buildShortcutGrid();
}
function triggerNote(note, dur = null) {
if (!FREQS[note]) return;
const d = dur !== null ? dur : (rightShift ? 3.5 : leftShift ? 0.15 : 1.4);
playFreq(FREQS[note], d);
addNoteToTicker(note);
document.getElementById('statusInfo').textContent = `♩ ${note} — ${FREQS[note].toFixed(2)} Hz`;
const el = document.querySelector(`[data-note="${note}"]`);
if (el) {
el.classList.add('active');
setTimeout(() => el.classList.remove('active'), Math.min(d * 900, 1200));
}
}
document.addEventListener('keydown', e => {
if (e.key === 'Shift') {
if (e.location === 1) leftShift = true;
else rightShift = true;
return;
}
if (e.repeat) return;
if (e.key === '[') { if (octaveOffset > OCT_MIN) { octaveOffset--; updateOctaveUI(); } return; }
if (e.key === ']') { if (octaveOffset < OCT_MAX) { octaveOffset++; updateOctaveUI(); } return; }
const k = e.key.toLowerCase();
const ak = getActiveKeymap();
if (ak[k]) triggerNote(ak[k]);
});
document.addEventListener('keyup', e => {
if (e.key === 'Shift') {
if (e.location === 1) leftShift = false;
else rightShift = false;
}
});
// ── Melodies ───────────────────────────────────────────────────────
const MELODIES = [
{ name:"Twinkle Twinkle Little Star", bpm:100, notes:[
{n:"C5",d:1},{n:"C5",d:1},{n:"G5",d:1},{n:"G5",d:1},{n:"A5",d:1},{n:"A5",d:1},{n:"G5",d:2},
{n:"F5",d:1},{n:"F5",d:1},{n:"E5",d:1},{n:"E5",d:1},{n:"D5",d:1},{n:"D5",d:1},{n:"C5",d:2},
{n:"G5",d:1},{n:"G5",d:1},{n:"F5",d:1},{n:"F5",d:1},{n:"E5",d:1},{n:"E5",d:1},{n:"D5",d:2},
{n:"G5",d:1},{n:"G5",d:1},{n:"F5",d:1},{n:"F5",d:1},{n:"E5",d:1},{n:"E5",d:1},{n:"D5",d:2},
{n:"C5",d:1},{n:"C5",d:1},{n:"G5",d:1},{n:"G5",d:1},{n:"A5",d:1},{n:"A5",d:1},{n:"G5",d:2},
{n:"F5",d:1},{n:"F5",d:1},{n:"E5",d:1},{n:"E5",d:1},{n:"D5",d:1},{n:"D5",d:1},{n:"C5",d:2}
]},
{ name:"Happy Birthday", bpm:90, notes:[
{n:"G4",d:0.75},{n:"G4",d:0.25},{n:"A4",d:1},{n:"G4",d:1},{n:"C5",d:1},{n:"B4",d:2},
{n:"G4",d:0.75},{n:"G4",d:0.25},{n:"A4",d:1},{n:"G4",d:1},{n:"D5",d:1},{n:"C5",d:2},
{n:"G4",d:0.75},{n:"G4",d:0.25},{n:"G5",d:1},{n:"E5",d:1},{n:"C5",d:1},{n:"B4",d:1},{n:"A4",d:2},
{n:"F5",d:0.75},{n:"F5",d:0.25},{n:"E5",d:1},{n:"C5",d:1},{n:"D5",d:1},{n:"C5",d:2}
]},
{ name:"Mary Had a Little Lamb", bpm:110, notes:[
{n:"E5",d:1},{n:"D5",d:1},{n:"C5",d:1},{n:"D5",d:1},{n:"E5",d:1},{n:"E5",d:1},{n:"E5",d:2},
{n:"D5",d:1},{n:"D5",d:1},{n:"D5",d:2},{n:"E5",d:1},{n:"G5",d:1},{n:"G5",d:2},
{n:"E5",d:1},{n:"D5",d:1},{n:"C5",d:1},{n:"D5",d:1},{n:"E5",d:1},{n:"E5",d:1},{n:"E5",d:1},{n:"E5",d:1},
{n:"D5",d:1},{n:"D5",d:1},{n:"E5",d:1},{n:"D5",d:1},{n:"C5",d:2}
]},
{ name:"Ode to Joy", bpm:100, notes:[
{n:"E4",d:1},{n:"E4",d:1},{n:"F4",d:1},{n:"G4",d:1},{n:"G4",d:1},{n:"F4",d:1},{n:"E4",d:1},{n:"D4",d:1},
{n:"C4",d:1},{n:"C4",d:1},{n:"D4",d:1},{n:"E4",d:1},{n:"E4",d:1.5},{n:"D4",d:0.5},{n:"D4",d:2},
{n:"E4",d:1},{n:"E4",d:1},{n:"F4",d:1},{n:"G4",d:1},{n:"G4",d:1},{n:"F4",d:1},{n:"E4",d:1},{n:"D4",d:1},
{n:"C4",d:1},{n:"C4",d:1},{n:"D4",d:1},{n:"E4",d:1},{n:"D4",d:1.5},{n:"C4",d:0.5},{n:"C4",d:2}
]},
{ name:"Jingle Bells", bpm:120, notes:[
{n:"E4",d:1},{n:"E4",d:1},{n:"E4",d:2},{n:"E4",d:1},{n:"E4",d:1},{n:"E4",d:2},
{n:"E4",d:1},{n:"G4",d:1},{n:"C4",d:1},{n:"D4",d:1},{n:"E4",d:4},
{n:"F4",d:1},{n:"F4",d:1},{n:"F4",d:1},{n:"F4",d:1},
{n:"F4",d:1},{n:"E4",d:1},{n:"E4",d:1},{n:"E4",d:1},
{n:"G4",d:1},{n:"G4",d:1},{n:"F4",d:1},{n:"D4",d:1},{n:"C4",d:4}
]},
{ name:"Hot Cross Buns", bpm:100, notes:[
{n:"E5",d:1},{n:"D5",d:1},{n:"C5",d:2},
{n:"E5",d:1},{n:"D5",d:1},{n:"C5",d:2},
{n:"C5",d:0.5},{n:"C5",d:0.5},{n:"C5",d:0.5},{n:"C5",d:0.5},{n:"D5",d:0.5},{n:"D5",d:0.5},{n:"D5",d:0.5},{n:"D5",d:0.5},
{n:"E5",d:1},{n:"D5",d:1},{n:"C5",d:2}
]},
{ name:"London Bridge", bpm:110, notes:[
{n:"G4",d:1},{n:"A4",d:1},{n:"G4",d:1},{n:"F4",d:1},{n:"E4",d:1},{n:"F4",d:1},{n:"G4",d:2},
{n:"D4",d:1},{n:"E4",d:1},{n:"F4",d:2},{n:"E4",d:1},{n:"F4",d:1},{n:"G4",d:2},
{n:"G4",d:1},{n:"A4",d:1},{n:"G4",d:1},{n:"F4",d:1},{n:"E4",d:1},{n:"F4",d:1},{n:"G4",d:1},{n:"D4",d:1},
{n:"E4",d:1},{n:"G4",d:1},{n:"C4",d:2}
]},
{ name:"Frère Jacques", bpm:100, notes:[
{n:"C4",d:1},{n:"D4",d:1},{n:"E4",d:1},{n:"C4",d:1},
{n:"C4",d:1},{n:"D4",d:1},{n:"E4",d:1},{n:"C4",d:1},
{n:"E4",d:1},{n:"F4",d:1},{n:"G4",d:2},
{n:"E4",d:1},{n:"F4",d:1},{n:"G4",d:2},
{n:"G4",d:0.5},{n:"A4",d:0.5},{n:"G4",d:0.5},{n:"F4",d:0.5},{n:"E4",d:1},{n:"C4",d:1},
{n:"G4",d:0.5},{n:"A4",d:0.5},{n:"G4",d:0.5},{n:"F4",d:0.5},{n:"E4",d:1},{n:"C4",d:1},
{n:"C4",d:1},{n:"G3",d:1},{n:"C4",d:2},
{n:"C4",d:1},{n:"G3",d:1},{n:"C4",d:2}
]},
{ name:"Für Elise (Opening)", bpm:90, notes:[
{n:"E5",d:0.5},{n:"D#5",d:0.5},{n:"E5",d:0.5},{n:"D#5",d:0.5},{n:"E5",d:0.5},
{n:"B4",d:0.5},{n:"D5",d:0.5},{n:"C5",d:0.5},{n:"A4",d:1.5},
{n:"C4",d:0.5},{n:"E4",d:0.5},{n:"A4",d:0.5},{n:"B4",d:1.5},
{n:"E4",d:0.5},{n:"G#4",d:0.5},{n:"B4",d:0.5},{n:"C5",d:1.5},
{n:"E4",d:0.5},{n:"E5",d:0.5},{n:"D#5",d:0.5},{n:"E5",d:0.5},
{n:"D#5",d:0.5},{n:"E5",d:0.5},{n:"B4",d:0.5},{n:"D5",d:0.5},{n:"C5",d:0.5},{n:"A4",d:2}
]},
{ name:"Yankee Doodle", bpm:120, notes:[
{n:"C4",d:1},{n:"C4",d:1},{n:"D4",d:1},{n:"E4",d:1},{n:"C4",d:1},{n:"E4",d:1},{n:"D4",d:2},
{n:"C4",d:1},{n:"C4",d:1},{n:"D4",d:1},{n:"E4",d:1},{n:"C4",d:2},{n:"B3",d:2},
{n:"C4",d:1},{n:"C4",d:1},{n:"D4",d:1},{n:"E4",d:1},{n:"F4",d:1},{n:"E4",d:1},{n:"D4",d:1},{n:"C4",d:1},
{n:"B3",d:1},{n:"G3",d:1},{n:"A4",d:1},{n:"B4",d:1},{n:"C5",d:2}
]},
{ name:"When the Saints Go Marching In", bpm:110, notes:[
{n:"C4",d:1},{n:"E4",d:1},{n:"F4",d:1},{n:"G4",d:2},
{n:"C4",d:1},{n:"E4",d:1},{n:"F4",d:1},{n:"G4",d:2},
{n:"C4",d:1},{n:"E4",d:1},{n:"F4",d:1},{n:"G4",d:1},{n:"E4",d:1},{n:"C4",d:1},{n:"E4",d:1},{n:"D4",d:2},
{n:"E4",d:1},{n:"E4",d:1},{n:"D4",d:1},{n:"C4",d:1},{n:"E4",d:1},{n:"G4",d:1},{n:"G4",d:1},{n:"F4",d:2}
]},
{ name:"Amazing Grace", bpm:80, notes:[
{n:"C4",d:1},{n:"F4",d:2},{n:"A4",d:1},{n:"F4",d:2},{n:"A4",d:1},
{n:"G4",d:2},{n:"F4",d:1},{n:"D4",d:2},{n:"F4",d:1},
{n:"G4",d:2},{n:"A4",d:1},{n:"F4",d:2},
{n:"C4",d:1},{n:"F4",d:2},{n:"A4",d:1},{n:"C5",d:2},{n:"A4",d:1},
{n:"G4",d:2},{n:"F4",d:3}
]},
{ name:"Old MacDonald Had a Farm", bpm:110, notes:[
{n:"G4",d:1},{n:"G4",d:1},{n:"G4",d:1},{n:"D4",d:1},{n:"E4",d:1},{n:"E4",d:1},{n:"D4",d:2},
{n:"B4",d:1},{n:"B4",d:1},{n:"A4",d:1},{n:"A4",d:1},{n:"G4",d:4},
{n:"D4",d:1},{n:"D4",d:1},{n:"G4",d:1},{n:"G4",d:1},{n:"G4",d:1},{n:"G4",d:1},{n:"G4",d:1},{n:"D4",d:1},
{n:"E4",d:1},{n:"E4",d:1},{n:"D4",d:2}
]},
{ name:"Do Re Mi (C Major Scale)", bpm:100, notes:[
{n:"C4",d:1},{n:"D4",d:1},{n:"E4",d:1},{n:"F4",d:1},{n:"G4",d:1},{n:"A4",d:1},{n:"B4",d:1},{n:"C5",d:2},
{n:"C5",d:1},{n:"B4",d:1},{n:"A4",d:1},{n:"G4",d:1},{n:"F4",d:1},{n:"E4",d:1},{n:"D4",d:1},{n:"C4",d:2},
{n:"C4",d:1},{n:"E4",d:1},{n:"G4",d:1},{n:"C5",d:2},
{n:"D4",d:1},{n:"F4",d:1},{n:"A4",d:1},{n:"D5",d:2},
{n:"E4",d:1},{n:"G4",d:1},{n:"B4",d:1},{n:"E5",d:2}
]},
{ name:"Chromatic Scale Practice", bpm:80, notes:[
{n:"C4",d:0.5},{n:"C#4",d:0.5},{n:"D4",d:0.5},{n:"D#4",d:0.5},
{n:"E4",d:0.5},{n:"F4",d:0.5},{n:"F#4",d:0.5},{n:"G4",d:0.5},
{n:"G#4",d:0.5},{n:"A4",d:0.5},{n:"A#4",d:0.5},{n:"B4",d:0.5},
{n:"C5",d:0.5},{n:"C#5",d:0.5},{n:"D5",d:0.5},{n:"D#5",d:0.5},
{n:"E5",d:0.5},{n:"F5",d:0.5},{n:"F#5",d:0.5},{n:"G5",d:0.5},
{n:"G#5",d:0.5},{n:"A5",d:0.5},{n:"A#5",d:0.5},{n:"B5",d:0.5},{n:"C6",d:1},
{n:"B5",d:0.5},{n:"A#5",d:0.5},{n:"A5",d:0.5},{n:"G#5",d:0.5},
{n:"G5",d:0.5},{n:"F#5",d:0.5},{n:"F5",d:0.5},{n:"E5",d:0.5},
{n:"D#5",d:0.5},{n:"D5",d:0.5},{n:"C#5",d:0.5},{n:"C5",d:0.5},
{n:"B4",d:0.5},{n:"A#4",d:0.5},{n:"A4",d:0.5},{n:"G#4",d:0.5},
{n:"G4",d:0.5},{n:"F#4",d:0.5},{n:"F4",d:0.5},{n:"E4",d:0.5},
{n:"D#4",d:0.5},{n:"D4",d:0.5},{n:"C#4",d:0.5},{n:"C4",d:1}
]}
];
// Populate melody select
const sel = document.getElementById('melodySelect');
MELODIES.forEach((m, i) => {
const o = document.createElement('option');
o.value = i; o.textContent = m.name;
sel.appendChild(o);
});
let activeMelody = null, melodyTimers = [];
function stopMelody() {
melodyTimers.forEach(clearTimeout);
melodyTimers = []; activeMelody = null;
document.getElementById('melodyBtn').textContent = 'Play';
document.getElementById('melodyBtn').classList.remove('playing');
document.getElementById('melodyInfo').textContent = '';
}
function startMelody(idx) {
stopMelody();
const m = MELODIES[idx];
activeMelody = idx;
document.getElementById('melodyBtn').textContent = 'Stop';
document.getElementById('melodyBtn').classList.add('playing');
document.getElementById('melodyInfo').textContent = `${m.name} — ${m.bpm} BPM`;
const beatSec = 60 / m.bpm;
let t = 0;
m.notes.forEach(({n, d}) => {
const id = setTimeout(() => {
if (activeMelody === idx) triggerNote(n, d * beatSec * 0.9);
}, t * beatSec * 1000);
melodyTimers.push(id);
t += d;
});
const endId = setTimeout(stopMelody, t * beatSec * 1000 + 500);
melodyTimers.push(endId);
}
document.getElementById('melodyBtn').addEventListener('click', () => {
if (activeMelody !== null) { stopMelody(); return; }
const v = sel.value;
if (v !== '') startMelody(+v);
});
// ── Shortcut reference ─────────────────────────────────────────────
function buildShortcutGrid() {
const grid = document.getElementById('shortcutGrid');
grid.innerHTML = '';
Object.entries(getActiveKeymap()).forEach(([k, n]) => {
const d = document.createElement('div');
d.className = 'shortcut-item';
d.innerHTML = `<span class="sk">${k.toUpperCase()}</span><span class="sn">${n}</span>`;
grid.appendChild(d);
});
}
// ── Note Ticker ─────────────────────────────────────────────────────
const ticker = document.getElementById('noteTicker');
const tctx = ticker.getContext('2d');
let TW, TH;
function resizeTicker() {
TW = ticker.offsetWidth; TH = ticker.offsetHeight;
ticker.width = TW; ticker.height = TH;
}
resizeTicker();
window.addEventListener('resize', resizeTicker);
const NOTE_COLORS = {
'C':'#ff2200','C#':'#ff6600','D':'#ffaa00','D#':'#ffd700',
'E':'#aaff00','F':'#00ff88','F#':'#00ffdd','G':'#00bbff',
'G#':'#0066ff','A':'#7700ff','A#':'#cc00ff','B':'#ff0088'
};
function getTickColor(note) {
return NOTE_COLORS[note.match(/^([A-G]#?)/)?.[1]] || '#ff2200';
}
const SYMBOLS = ['♩','♪','♫','♬'];
const floaters = [];
let frameCount = 0;
function addNoteToTicker(note) {
const color = getTickColor(note);
const symbol = SYMBOLS[Math.floor(Math.random() * SYMBOLS.length)];
floaters.push({
note, color, symbol,
x: -10,
baseY: TH * (0.28 + Math.random() * 0.22),
phase: Math.random() * Math.PI * 2,
speed: 1.1 + Math.random() * 0.7,
size: 28 + Math.random() * 14,
life: 1.0,
born: frameCount
});
}
function tickerFrame() {
requestAnimationFrame(tickerFrame);
frameCount++;
const W = TW || ticker.width, H = TH || ticker.height;
// dark fade
tctx.fillStyle = 'rgba(0,0,0,0.18)';
tctx.fillRect(0, 0, W, H);
// subtle staff lines
tctx.strokeStyle = 'rgba(255,255,255,0.04)';
tctx.lineWidth = 1;
for (let i = 0; i < 5; i++) {
const y = H * 0.18 + i * H * 0.13;
tctx.beginPath(); tctx.moveTo(0, y); tctx.lineTo(W, y); tctx.stroke();
}
for (let i = floaters.length - 1; i >= 0; i--) {
const f = floaters[i];
f.x += f.speed;
// fade in at start, fade out near right edge
const fadeIn = Math.min(1, f.x / 60);
const fadeOut = Math.max(0, 1 - Math.max(0, f.x - (W * 0.72)) / (W * 0.28));
const alpha = fadeIn * fadeOut;
if (f.x > W + 20) { floaters.splice(i, 1); continue; }
const age = frameCount - f.born;
const y = f.baseY + Math.sin(age * 0.045 + f.phase) * 9;
tctx.save();
tctx.globalAlpha = alpha;
// glow halo behind symbol
tctx.shadowBlur = 22;
tctx.shadowColor = f.color;
// music symbol
tctx.fillStyle = f.color;
tctx.font = `${f.size}px serif`;
tctx.textAlign = 'center';
tctx.textBaseline = 'middle';
tctx.fillText(f.symbol, f.x, y);
// extra glow pass (draw twice for intensity)
tctx.globalAlpha = alpha * 0.4;
tctx.shadowBlur = 40;
tctx.fillStyle = '#ffffff';
tctx.fillText(f.symbol, f.x, y);
// note name below
tctx.globalAlpha = alpha * 0.9;
tctx.shadowBlur = 10;
tctx.shadowColor = f.color;
tctx.fillStyle = f.color;
tctx.font = `bold 10px 'DM Mono', monospace`;
tctx.fillText(f.note, f.x, y + f.size * 0.72);
tctx.restore();
}
}
tickerFrame();
buildKeyboard();
buildShortcutGrid();