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)

tools/synth.py

#!/usr/bin/env python3
"""Regenerate samples/<midi>.ogg for Keyboard Piano.

A physical-ish piano model in pure Python, encoded to Ogg Vorbis by ffmpeg:

- every note is a stack of stiff-string partials, stretched sharp by an
  inharmonicity that grows up the keyboard, as many as fit under Nyquist
- the hammer strikes an eighth of the way along the string, which notches
  every eighth partial, and its felt rolls the spectrum off
- each partial decays in two stages, a fast attack decay and a long
  aftersound, and upper partials die sooner
- two or three strings per note, unequal and slightly out of tune, so the
  tone shimmers without ever beating down to silence
- a few milliseconds of filtered noise for the hammer knock

48 kHz mono, so the usual 48 kHz output device plays it without resampling.

    python3 tools/synth.py            # writes ./samples, needs ffmpeg
    python3 tools/synth.py out_dir
"""

import array
import math
import multiprocessing
import os
import random
import subprocess
import sys
import tempfile
import wave

RATE = 48000
LOW, HIGH = 36, 91  # C2..G6: the key map's range across every octave shift
HAMMER_AT = 0.125
PEAK = 0.85
VORBIS_QUALITY = "4"


def note_seconds(midi):
    # bass strings ring longest: 4.0 s at the bottom, 1.5 s at the top
    return 4.0 - 2.5 * (midi - LOW) / (HIGH - LOW)


def add_partial(out, freq, amp, t60, phase):
    """Add amp * exp(-t/tau) * sin(w t + phase) by a two-multiply recurrence."""
    w = 2 * math.pi * freq / RATE
    r = math.exp(-math.log(1000) / (t60 * RATE))
    c, r2 = 2 * r * math.cos(w), r * r
    y2 = amp * math.sin(phase - w) / r
    y1 = amp * math.sin(phase)
    for i in range(len(out)):
        out[i] += y1
        y1, y2 = c * y1 - r2 * y2, y1


def render(midi):
    rng = random.Random(midi)
    f0 = 440.0 * 2 ** ((midi - 69) / 12)
    n = int(RATE * note_seconds(midi))
    out = [0.0] * n

    up = (midi - LOW) / (HIGH - LOW)  # 0 at the bottom of the range, 1 at the top
    stiffness = 2e-4 * 2 ** ((midi - 48) / 12 * 1.2)
    felt = 2400 + 1800 * up  # Hz: where the hammer's spectrum has fallen by 1/e
    sustain = 11.0 * 2 ** (-2.6 * up)  # seconds for the fundamental's aftersound to fall 60 dB
    strings = [(0.0, 1.0)] if midi < 40 else [(0.0, 1.0), (0.7, 0.7)]
    if midi >= 48:
        strings.append((-1.0, 0.5))

    k = 1
    while k <= 64:
        stretched = k * f0 * math.sqrt(1 + stiffness * k * k)
        if stretched > RATE * 0.42:
            break
        amp = abs(math.sin(k * math.pi * HAMMER_AT)) * math.exp(-stretched / felt) / k**0.8
        if amp > 1e-4:
            t60 = sustain / (1 + 0.14 * (k - 1))
            for cents, weight in strings:
                freq = stretched * 2 ** (cents / 1200)
                phase = rng.uniform(0, 2 * math.pi)
                add_partial(out, freq, amp * weight * 0.7, t60 * 0.3, phase)
                add_partial(out, freq, amp * weight * 0.3, t60, phase)
        k += 1

    # the hammer knock: a short burst of low-passed noise
    body = max(abs(s) for s in out)
    smooth = math.exp(-2 * math.pi * (900 + 2500 * up) / RATE)
    low = 0.0
    for i in range(int(RATE * 0.03)):
        low = smooth * low + (1 - smooth) * rng.uniform(-1, 1)
        out[i] += 0.5 * body * low * math.exp(-i / (RATE * 0.006))

    attack, release = int(RATE * 0.002), int(RATE * 0.08)
    for i in range(attack):
        out[i] *= 0.5 - 0.5 * math.cos(math.pi * i / attack)
    for i in range(release):
        out[n - 1 - i] *= i / release

    scale = PEAK * 32767 / max(abs(s) for s in out)
    return array.array("h", (int(s * scale) for s in out))


def write_note(job):
    midi, out_dir, tmp = job
    pcm = render(midi)
    if sys.byteorder == "big":
        pcm.byteswap()
    wav = os.path.join(tmp, f"{midi}.wav")
    with wave.open(wav, "wb") as f:
        f.setnchannels(1)
        f.setsampwidth(2)
        f.setframerate(RATE)
        f.writeframes(pcm.tobytes())
    subprocess.run(
        ["ffmpeg", "-v", "error", "-y", "-i", wav, "-c:a", "libvorbis", "-q:a", VORBIS_QUALITY,
         "-map_metadata", "-1", "-fflags", "+bitexact", os.path.join(out_dir, f"{midi}.ogg")],
        check=True,
    )
    return f"{midi}.ogg  {len(pcm) / RATE:.2f} s"


def main():
    out_dir = sys.argv[1] if len(sys.argv) > 1 else "samples"
    os.makedirs(out_dir, exist_ok=True)
    with tempfile.TemporaryDirectory() as tmp, multiprocessing.Pool() as pool:
        jobs = [(midi, out_dir, tmp) for midi in range(HIGH, LOW - 1, -1)]
        for line in pool.imap_unordered(write_note, jobs):
            print(line)


if __name__ == "__main__":
    main()

56 files without text previews