marketplace/audio

Keyboard Piano

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

@rebindv0.1.0free

More install options
$rebind install @rebind/keyboard-piano

Gallery

Keyboard Piano preview

Readme

Your keyboard plays piano notes. Hold several keys for a chord; a note rings while its key is down and fades when you let go.

Keys

The bottom two letter rows are the lower octave, the top two are the upper octave. White keys sit on the Z and Q rows, black keys on the row above each.

NotesWhite keysBlack keys
C3 to E4Z X C V B N M , . /S D G H J L ;
C4 to G5Q W E R T Y U I O P [ ]2 3 5 6 7 9 0 =
KeyDoes
SpaceSustain pedal. Notes released while it is held keep ringing until you let it go.
Left, RightShift every key down or up an octave, one octave either way.
F9Plays the chosen song by itself, both hands. Press it again to stop. The keys stay live, so you can play along.
F8Piano off or on. While it is off the keys type normally.

Those five keys, the note volume and the damper time are configurable. Restart the script after changing a key.

Chords that include Ctrl, Alt or Cmd/Win are left alone, so shortcuts keep working while the piano is on.

The game

Starting the script opens http://localhost:47800 in your default browser. Blocks fall toward a piano, each printed with the key that plays it. Press that key as the block lands on the line.

  • Hits within 70 ms score Perfect, within 160 ms Early or Late, and anything else is a miss. A combo multiplies the score and a wrong key breaks it. Each run ends with a grade, and the page keeps your best score per song and speed.
  • The piano plays the left hand while you play the melody. Watch it played has it play both hands.
  • Three speeds: slow, medium and full.

Five songs, easiest first: Twinkle, Twinkle, Little Star; Ode to Joy; Jingle Bells; Minuet in G; Für Elise. All are in the public domain, and every melody fits the upper key row without an octave shift. The song you pick on the page is the one F9 plays.

The package declares two permissions. net serves the page and makes no outbound connection. exec is used once, to open the page in your browser; turn off "Open the game page when the script starts" under Game to skip it. The page is served on localhost only. The WebSocket that feeds it, port 47801, accepts connections from your local network; it sends the notes being played and accepts only a song choice, play and stop. Turn off "Serve the game page" and restart the script to open no ports at all.

Sound

The notes are synthesized, not recorded from an instrument: one Ogg Vorbis file per note under samples/, from C2 to G6, at 48 kHz. The model stacks the stretched partials of stiff strings, shapes them by where and how hard a felt hammer strikes, lets each one decay in two stages, and gives every note two or three slightly mistuned strings and a short hammer knock. tools/synth.py regenerates the samples with Python's standard library and ffmpeg.

Limits

  • The note keys are captured system wide while the piano is on. Press the on or off key before you type.
  • Every key plays at the same loudness. A computer keyboard cannot tell how hard you press.
  • Many keyboards cannot report some combinations of three or more keys at once. A chord that drops a note is the keyboard's limit, not the script's.

Reviews

No reviews yet. Write a review in the app.

Source

Version 0.1.0 (current)

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