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();