Keyboard Piano

@rebindv0.1.4

Your keyboard becomes a piano with chords, sustain and octave shift. A lessons page in your browser teaches five songs.

Files

site/index.html

<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Keyboard Piano</title>
<style>
  :root {
    --bg: #17131f; --panel: #211b2d; --line: #352c47; --ink: #f2eefb;
    --dim: #a79dbd; --brand: #6750a4; --note: #ffc857; --good: #5fd39a;
    --bad: #ff6b6b; --song: #7cc4ff;
  }
  * { box-sizing: border-box; }
  body { margin: 0; background: var(--bg); color: var(--ink); font: 16px/1.4 Helvetica, Arial, sans-serif; }
  main { max-width: 900px; margin: 0 auto; padding: 20px 16px 32px; }
  button { font: inherit; color: inherit; cursor: pointer; }

  header { display: flex; align-items: center; gap: 12px; margin-bottom: 14px; }
  h1 { margin: 0; font-size: 22px; letter-spacing: -0.5px; }
  #link { margin-left: auto; font-size: 13px; color: var(--dim); }
  #link::before {
    content: ""; display: inline-block; width: 9px; height: 9px; margin-right: 7px;
    border-radius: 50%; background: var(--bad);
  }
  #link.up::before { background: var(--good); }

  .row { display: flex; gap: 8px; flex-wrap: wrap; margin-bottom: 10px; }
  .pick {
    padding: 8px 12px; border: 1px solid var(--line); border-radius: 6px; background: var(--panel);
    font-size: 14px;
  }
  .pick[aria-pressed="true"] { background: var(--brand); border-color: var(--brand); }
  .pick small { color: var(--dim); margin-left: 6px; }
  .pick[aria-pressed="true"] small { color: #ddd3f5; }
  #go { padding: 8px 22px; border: 0; border-radius: 6px; background: var(--ink); color: var(--bg); font-weight: 700; }
  .gap { margin-left: auto; }

  #hud { display: flex; align-items: baseline; gap: 22px; margin: 14px 2px 8px; }
  #hud div { font-size: 13px; color: var(--dim); }
  #hud b { display: block; font-size: 30px; letter-spacing: -1px; color: var(--ink); font-variant-numeric: tabular-nums; }
  #best { margin-left: auto; text-align: right; }

  #stage { position: relative; border: 1px solid var(--line); border-radius: 8px; overflow: hidden; background: var(--panel); }
  #lane { position: relative; height: 380px; overflow: hidden; }
  #lane::after {
    content: ""; position: absolute; left: 0; right: 0; bottom: 0; height: 3px; background: var(--ink); opacity: 0.85;
  }
  .note {
    position: absolute; top: 0; display: flex; align-items: flex-end; justify-content: center;
    padding-bottom: 3px; border-radius: 5px; background: var(--note); color: #1d1500;
    font-weight: 700; font-size: 15px; will-change: transform;
  }
  .note.sharp { background: #e0a93a; }
  .note.hit { background: var(--good); opacity: 0; transition: opacity 0.25s; }
  .note.miss { background: var(--bad); opacity: 0.35; }

  #call {
    position: absolute; left: 0; right: 0; top: 40%; text-align: center; pointer-events: none;
    font-size: 44px; font-weight: 700; letter-spacing: -1.5px;
  }
  #call.perfect { color: var(--good); }
  #call.good { color: var(--note); }
  #call.miss { color: var(--bad); }
  #call.fade { opacity: 0; transition: opacity 0.5s; }

  #keys { position: relative; height: 140px; user-select: none; }
  .key {
    position: absolute; top: 0; display: flex; flex-direction: column; justify-content: flex-end;
    align-items: center; padding-bottom: 8px; border-radius: 0 0 5px 5px;
  }
  .key.white { height: 100%; background: #f6f3fc; border: 1px solid #bdb5cf; }
  .key.black { height: 60%; z-index: 1; background: #120e19; border: 1px solid #000; }
  .key kbd { font: 700 17px Helvetica, Arial, sans-serif; color: #2b2440; }
  .key.black kbd { color: #efeaf9; font-size: 14px; }
  .key.song { background: var(--song); }
  .key.down { background: var(--good); }
  .key.stray { background: var(--bad); }

  #results {
    position: absolute; inset: 0; display: none; flex-direction: column; align-items: center;
    justify-content: center; gap: 6px; background: rgba(23, 19, 31, 0.93); text-align: center;
  }
  #results.show { display: flex; }
  #grade { font-size: 120px; font-weight: 700; line-height: 1; letter-spacing: -4px; }
  #results p { margin: 0; color: var(--dim); }
  #results b { color: var(--ink); }

  #hint { margin-top: 12px; color: var(--dim); font-size: 14px; }
</style>
</head>
<body>
<main>
  <header>
    <h1>Keyboard Piano</h1>
    <div id="link">Connecting to the piano</div>
  </header>

  <div class="row" id="songs"></div>
  <div class="row">
    <button class="pick" data-speed="0.6">Slow</button>
    <button class="pick" data-speed="0.8">Medium</button>
    <button class="pick" data-speed="1">Full speed</button>
    <button class="pick" id="watch" aria-pressed="false">Watch it played</button>
    <button id="go" class="gap">Play</button>
  </div>

  <div id="hud">
    <div><b id="score">0</b>score</div>
    <div><b id="combo">0</b>combo</div>
    <div><b id="acc">0%</b>on time</div>
    <div id="best"></div>
  </div>

  <div id="stage">
    <div id="lane"></div>
    <div id="call"></div>
    <div id="keys"></div>
    <div id="results">
      <div id="grade"></div>
      <p id="tally"></p>
      <p id="record"></p>
    </div>
  </div>

  <p id="hint"></p>
</main>

<script>
const SHARPS = [1, 3, 6, 8, 10];
const KEY_LABELS = {
  Comma: ",", Period: ".", Semicolon: ";", Slash: "/",
  LeftBrace: "[", RightBrace: "]", Equal: "=",
};
const LOW = 60, HIGH = 79;   // the upper key row: every melody lives here
const LEAD = 3000;           // ms of count-in before the first step
const PX_PER_MS = 0.19;      // fall speed
const PERFECT = 70, GOOD = 160;

const $ = (id) => document.getElementById(id);
let piano = null, socket = null, octave = 0;
let song = null, speed = 1;
let notes = [];              // { note, t, ms, el, state }
let mode = "idle";           // idle | play | watch
let watching = false;        // what Play starts: the game, or the piano playing it
let startAt = 0, endAt = 0, frame = 0;
let score = 0, combo = 0, bestCombo = 0, perfect = 0, good = 0, missed = 0;

const isSharp = (n) => SHARPS.includes(n % 12);

// the computer key that plays `note` right now. the upper row wins where the
// two rows overlap.
function labelFor(note) {
  let found = null;
  for (const [key, semitone] of Object.entries(piano.keys)) {
    if (piano.base + semitone + 12 * octave !== note) continue;
    const label = KEY_LABELS[key] || key;
    if (found === null || "QWERTYUIOP[]234567890=".includes(label)) found = label;
  }
  return found || "";
}

function buildKeys() {
  const box = $("keys");
  box.textContent = "";
  let whites = 0;
  for (let n = LOW; n <= HIGH; n++) if (!isSharp(n)) whites++;
  const width = 100 / whites;
  let x = 0;
  for (let n = LOW; n <= HIGH; n++) {
    const el = document.createElement("div");
    el.className = "key " + (isSharp(n) ? "black" : "white");
    el.dataset.note = n;
    el.style.width = (isSharp(n) ? width * 0.62 : width) + "%";
    el.style.left = (isSharp(n) ? x - width * 0.31 : x) + "%";
    el.appendChild(document.createElement("kbd"));
    box.appendChild(el);
    if (!isSharp(n)) x += width;
  }
  labelKeys();
}

function labelKeys() {
  for (const el of $("keys").children) el.firstChild.textContent = labelFor(Number(el.dataset.note));
  for (const n of notes) n.el.textContent = labelFor(n.note);
  $("hint").textContent = octave !== 0
    ? "The octave is shifted. Press Left or Right on the piano to return to the middle, or follow the new letters."
    : watching
      ? "Press Play and the piano plays both hands while the blocks fall. Nothing is scored."
      : "Press Play, then press the key printed on each block as it lands on the line. The piano plays the left hand for you.";
}

const keyEl = (note) => $("keys").querySelector('[data-note="' + note + '"]');

function flag(note, cls, on) {
  const el = keyEl(note);
  if (el) el.classList.toggle(cls, on);
}

// lay the melody out in time. blocks share their key's column.
function buildNotes() {
  const lane = $("lane");
  lane.textContent = "";
  notes = [];
  const stepMs = song.step / speed;
  let steps = 0;
  for (let i = 0; i < song.right.length; i += 2) {
    const note = song.right[i], len = song.right[i + 1];
    if (note !== 0) {
      const key = keyEl(note);
      const el = document.createElement("div");
      el.className = "note" + (isSharp(note) ? " sharp" : "");
      el.style.left = key.style.left;
      el.style.width = key.style.width;
      el.style.height = Math.max(24, len * stepMs * PX_PER_MS - 3) + "px";
      el.textContent = labelFor(note);
      lane.appendChild(el);
      notes.push({ note, t: steps * stepMs, ms: len * stepMs, el, state: "wait" });
    }
    steps += len;
  }
  endAt = steps * stepMs + 700;
  place(-LEAD);
}

function place(now) {
  const line = $("lane").clientHeight;
  for (const n of notes) {
    const y = line - (n.t - now) * PX_PER_MS - n.el.offsetHeight;
    n.el.style.transform = "translateY(" + y + "px)";
  }
}

function call(text, cls) {
  const el = $("call");
  el.className = cls;
  el.textContent = text;
  void el.offsetWidth;
  el.classList.add("fade");
}

function hud() {
  $("score").textContent = Math.round(score);
  $("combo").textContent = combo;
  const judged = perfect + good + missed;
  $("acc").textContent = (judged ? Math.round(100 * (perfect + good) / judged) : 0) + "%";
}

function showBest() {
  const best = song && localStorage.getItem("kp:" + song.id + ":" + speed);
  $("best").innerHTML = best ? "<b>" + best + "</b>best at this speed" : "";
}

function send(message) {
  if (socket && socket.readyState === 1) socket.send(JSON.stringify(message));
}

function stop() {
  cancelAnimationFrame(frame);
  if (mode !== "idle") send({ t: "stop" });
  mode = "idle";
  $("go").textContent = "Play";
  for (const el of $("keys").children) el.classList.remove("song", "down", "stray");
}

function begin(next) {
  stop();
  if (!song) return;
  score = combo = bestCombo = perfect = good = missed = 0;
  hud();
  $("results").classList.remove("show");
  buildNotes();
  mode = next;
  $("go").textContent = "Stop";
  startAt = performance.now() + LEAD;
  const run = startAt;
  // the piano starts its hands when the count-in ends
  setTimeout(() => {
    if (mode === next && startAt === run) send({ t: "play", id: song.id, hands: next === "play" ? "left" : "both", speed });
  }, LEAD);
  frame = requestAnimationFrame(tick);
}

function tick() {
  const now = performance.now() - startAt;
  if (now < 0) {
    const left = Math.ceil(-now / 1000);
    if ($("call").textContent !== String(left)) call(String(left), "");
  }
  for (const n of notes) {
    if (n.state !== "wait") continue;
    if (mode === "watch" && now >= n.t) mark(n, "hit");
    else if (mode === "play" && now > n.t + GOOD) {
      mark(n, "miss");
      missed += 1;
      combo = 0;
      call("Miss", "miss");
      hud();
    }
  }
  place(now);
  if (now > endAt) return finish();
  frame = requestAnimationFrame(tick);
}

function mark(n, state) {
  n.state = state;
  n.el.classList.add(state);
}

function onDown(note, src) {
  if (src === "song") return flag(note, "song", true);
  flag(note, "down", true);
  if (mode !== "play") return;
  const now = performance.now() - startAt;
  let hit = null;
  for (const n of notes) {
    if (n.state !== "wait" || n.note !== note || Math.abs(now - n.t) > GOOD) continue;
    if (hit === null || Math.abs(now - n.t) < Math.abs(now - hit.t)) hit = n;
  }
  if (hit === null) {
    combo = 0;
    flag(note, "stray", true);
    return hud();
  }
  const exact = Math.abs(now - hit.t) <= PERFECT;
  mark(hit, "hit");
  combo += 1;
  bestCombo = Math.max(bestCombo, combo);
  if (exact) perfect += 1; else good += 1;
  score += (exact ? 100 : 50) * (1 + Math.min(combo, 40) / 20);
  call(exact ? "Perfect" : (now < hit.t ? "Early" : "Late"), exact ? "perfect" : "good");
  hud();
}

function onUp(note, src) {
  if (src === "song") return flag(note, "song", false);
  flag(note, "down", false);
  flag(note, "stray", false);
}

function finish() {
  const played = mode === "play";
  stop();
  if (!played) return;
  const total = notes.length;
  const rate = (perfect + good * 0.5) / total;
  $("grade").textContent = rate >= 0.95 ? "S" : rate >= 0.85 ? "A" : rate >= 0.7 ? "B" : rate >= 0.5 ? "C" : "D";
  $("tally").innerHTML = "<b>" + perfect + "</b> perfect · <b>" + good + "</b> early or late · <b>"
    + missed + "</b> missed · best combo <b>" + bestCombo + "</b>";
  const key = "kp:" + song.id + ":" + speed;
  const points = Math.round(score);
  const record = points > Number(localStorage.getItem(key) || 0);
  if (record) localStorage.setItem(key, points);
  $("record").textContent = record ? "New best: " + points : "Score " + points;
  $("results").classList.add("show");
  showBest();
}

function choose(id, tell) {
  stop();
  song = piano.songs.find((s) => s.id === id);
  for (const b of $("songs").children) b.setAttribute("aria-pressed", String(b.dataset.id === id));
  if (tell) send({ t: "select", id });
  $("results").classList.remove("show");
  buildNotes();
  showBest();
}

function setSpeed(value) {
  speed = value;
  for (const b of document.querySelectorAll("[data-speed]")) b.setAttribute("aria-pressed", String(Number(b.dataset.speed) === speed));
  if (song) choose(song.id, false);
}

function connect() {
  socket = new WebSocket("ws://" + location.hostname + ":" + piano.wsPort);
  socket.onopen = () => {
    $("link").className = "up";
    $("link").textContent = "Piano connected";
  };
  socket.onclose = () => {
    $("link").className = "";
    $("link").textContent = "Piano not running. Retrying.";
    setTimeout(connect, 1500);
  };
  socket.onmessage = (event) => {
    const m = JSON.parse(event.data);
    if (m.t === "down") onDown(m.note, m.src);
    else if (m.t === "up") onUp(m.note, m.src);
    else if (m.t === "octave") { octave = m.octave; labelKeys(); }
    else if (m.t === "hello") { octave = m.octave; labelKeys(); if (!song) choose(m.selected, false); }
  };
}

$("go").onclick = () => (mode === "idle" ? begin(watching ? "watch" : "play") : stop());
$("watch").onclick = () => {
  stop();
  watching = !watching;
  $("watch").setAttribute("aria-pressed", String(watching));
  $("results").classList.remove("show");
  labelKeys();
};
for (const b of document.querySelectorAll("[data-speed]")) b.onclick = () => setSpeed(Number(b.dataset.speed));
window.addEventListener("resize", () => { if (mode === "idle" && song) place(-LEAD); });

fetch("/piano.json").then((r) => r.json()).then((data) => {
  piano = data;
  for (const s of piano.songs) {
    const b = document.createElement("button");
    b.className = "pick";
    b.dataset.id = s.id;
    b.textContent = s.title;
    const level = document.createElement("small");
    level.textContent = s.level;
    b.appendChild(level);
    b.onclick = () => choose(s.id, true);
    $("songs").appendChild(b);
  }
  buildKeys();
  setSpeed(speed);
  connect();
});
</script>
</body>
</html>

56 files without text previews