There is a particular kind of magic in pressing a key and hearing a sound come out. It is the first thing every child does when they find a piano, and it never quite stops being satisfying. So when I decided to build Stellix Piano — a piano you can play right inside your browser — the goal was never to make a serious music production tool. It was to recreate that small moment of delight, on any device, with nothing to install.
In this post I want to walk you through how a tool like this actually comes together. No code, no jargon — just the thinking behind each piece, so you understand what makes a virtual instrument feel alive rather than flat.
The problem with silent buttons
The obvious way to build a piano is to draw some keys and attach a sound file to each one. It works, technically. But it has two problems that show up immediately.
The first is weight. A decent piano sample set is enormous. Loading dozens of audio files before someone can press a single key means a slow, frustrating first impression.
The second is expression. A recorded sample plays exactly the same way every time, and it either stops abruptly or fades on a schedule you never chose. Real instruments do not behave like that. A note has a beginning, a body and an ending, and how you hold the key changes all three.
So Stellix Piano takes a different route. Instead of playing recordings, it generates every note on the spot, right in your browser. Nothing to download, nothing to wait for, and complete control over how each note behaves.
Turning numbers into notes
At the heart of this is a simple idea: sound is just vibration, and pitch is just how fast something vibrates. Middle C vibrates at roughly 261 times a second. The A above it vibrates at exactly 440 — the reference pitch orchestras have tuned to for a century.
Every note on a keyboard sits at a fixed mathematical distance from every other note. Move up one key and the frequency multiplies by a constant amount. Move up twelve keys, a full octave, and the frequency doubles exactly. That relationship is the whole musical scale in one sentence, and once your tool knows it, it can work out the pitch of any key on any octave without needing a single recorded sample.
That is why the octave buttons in Stellix Piano feel instant. Shifting up an octave is not loading a different set of sounds. It is just multiplying by two.
The part that makes it sound like an instrument
Here is where most beginner attempts fall down. If you simply switch a tone on when a key goes down and off when it comes up, what you hear is not a piano. It is a buzzer. Harsh, mechanical, unpleasant.
What separates an instrument from a buzzer is the shape of the sound over time — musicians call it the envelope. Think about hitting a real piano key. There is a fast, sharp attack as the hammer strikes. Then the sound decays quickly to a quieter level. It holds there while your finger stays down, and when you lift, it fades away rather than stopping dead.
Stellix Piano applies that same shape to every note. A quick swell at the start, a natural drop-off, a held body, and a soft release when you let go. Change the numbers in that shape and you change the instrument entirely — which is exactly how the tool offers different sounds. A piano has a sharp attack and a fast decay. An organ holds steady for as long as you press. A bell rings out long after you have lifted your finger. Same underlying engine, four different personalities, just by adjusting when the sound rises and falls.
The raw tone matters too. A smooth, rounded waveform sounds soft and flute-like. A jagged one sounds bright and buzzy, the classic synth character. Pairing the right tone with the right envelope is what makes each instrument recognisable.
Drawing a keyboard that behaves like one
Visually, a piano keyboard is trickier than it looks. White keys sit side by side in an even row. Black keys are narrower, shorter, sit on top, and — crucially — are not evenly spaced. There are two, then a gap, then three, then a gap. That irregular pattern is the thing your eye recognises instantly as “piano,” so getting it wrong makes the whole thing look off.
The approach is to lay the white keys out first as equal slices across the available width, then place each black key straddling the boundary between two specific white keys. Because everything is measured in proportions rather than fixed pixels, the entire keyboard scales smoothly from a wide desktop screen down to a phone without any of the keys drifting out of alignment.
Then comes the feedback. When you press a key it lights up with a glow, and the note name appears above the keyboard. Neither of these affects the sound at all, but both matter enormously. They confirm that the tool heard you, and — for anyone learning — they quietly teach which key is which.
Three ways to play
A virtual instrument is only as good as how easily you can play it. Stellix Piano accepts input three ways.
Mouse is the obvious one: click a key, hear a note. Touch matters more than people expect, since most visitors arrive on a phone, and taps need to register instantly without the browser trying to zoom or scroll instead.
But the one that changes everything is the computer keyboard. Mapping the piano onto the letter keys — the home row for white notes, the row above for the black ones — means you can suddenly play with all ten fingers instead of one clumsy cursor. Chords become possible. Actual melodies become possible. Small key hints printed on the keys tell you which letter does what, and they can be switched off once you no longer need them.
The feature that makes people stay
Everything above gets you a piano. What turns it into something people come back to is record and replay.
The idea is simple: while recording, the tool notes down every key press and release along with the exact moment it happened. Playing back just means replaying those same actions with the same gaps between them. Because it stores the performance rather than the audio, playback is perfect every time and takes almost no memory at all.
That small addition changes the whole experience. Suddenly you are not just poking at keys, you are making something you can hear back. That is the difference between a toy and a creative tool.
The lesson under the hood
If there is one takeaway from building an instrument, it is that realism comes from the small, invisible details. Nobody notices the envelope shape, but everyone notices when it is missing. Nobody thinks about black key spacing, but everyone feels it when it is wrong. The glow and the note name change nothing technically, yet they make the whole thing feel responsive and alive.
Good tools are usually like this. One honest idea, then a handful of careful details stacked on top until the thing stops feeling like software and starts feeling like an object you can use.
Stellix Piano is one of a growing set of small, free tools I build under Coding Stellix — each designed to do one job well and get out of your way. Open it, press a key, and see what you come up with.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover, user-scalable=no">
<title>Stellix Piano — Playable Online Piano & Synth by Coding Stellix</title>
<meta name="description" content="Stellix Piano by Coding Stellix — a playable online piano and synth. Play with mouse, touch or keyboard. Six instruments, octave shift, sustain, volume, learn-a-song mode and record & replay. Fully responsive on all devices.">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Unbounded:wght@400;600;700;800&family=Jost:wght@400;500;600;700&display=swap" rel="stylesheet">
<style>
:root{
--bg:#0c0a16;
--surface:#161228;
--surface-2:#201a38;
--surface-3:#2b2348;
--line:rgba(255,255,255,.09);
--text:#f2eefb;
--muted:#968cb4;
--c1:#ff3d9a;
--c2:#8b5cf6;
--c3:#22d3ee;
--r:14px;
}
*{margin:0;padding:0;box-sizing:border-box;-webkit-tap-highlight-color:transparent;user-select:none;-webkit-user-select:none}
html,body{height:100%;overscroll-behavior:none}
body{
font-family:'Jost',sans-serif;background:var(--bg);color:var(--text);
height:100dvh;display:flex;flex-direction:column;overflow:hidden;
background-image:
radial-gradient(50% 40% at 12% -6%, rgba(255,61,154,.16), transparent 60%),
radial-gradient(50% 40% at 88% -6%, rgba(34,211,238,.14), transparent 60%);
}
/* ================= HEADER ================= */
header{
display:flex;align-items:center;gap:10px;flex:none;
padding:clamp(8px,1.6vh,15px) clamp(12px,3vw,22px);
border-bottom:1px solid var(--line);
}
.logo{display:flex;align-items:center;gap:10px;min-width:0}
.logo-mark{
width:clamp(30px,4.4vw,38px);aspect-ratio:1;border-radius:11px;flex:none;
background:linear-gradient(135deg,var(--c1),var(--c2));
display:grid;place-items:center;font-family:'Unbounded',sans-serif;font-weight:800;
font-size:clamp(12px,1.6vw,15px);color:#0c0a16;box-shadow:0 4px 20px rgba(192,77,255,.32);
}
.logo-text{min-width:0}
.logo-text b{font-family:'Unbounded',sans-serif;font-weight:700;font-size:clamp(13px,1.9vw,16px);display:block;line-height:1.1;white-space:nowrap}
.logo-text span{font-size:10px;color:var(--muted);letter-spacing:.15em;text-transform:uppercase;white-space:nowrap}
.spacer{flex:1}
.hchip{font-size:12.5px;color:var(--muted);border:1px solid var(--line);border-radius:99px;padding:6px 13px;white-space:nowrap}
/* ================= LAYOUT ================= */
.wrap{
flex:1;min-height:0;width:100%;max-width:1180px;margin:0 auto;
padding:clamp(10px,1.8vh,20px) clamp(10px,2.5vw,18px) calc(clamp(10px,1.8vh,20px) + env(safe-area-inset-bottom));
display:flex;flex-direction:column;gap:clamp(8px,1.4vh,16px);
}
/* ================= CONTROLS ================= */
.controls{display:flex;align-items:center;gap:9px;flex-wrap:wrap;flex:none}
.group{
display:flex;align-items:center;gap:7px;background:var(--surface);
border:1px solid var(--line);border-radius:13px;padding:6px 9px;min-width:0;max-width:100%;
}
.group .lab{font-size:10px;letter-spacing:.12em;text-transform:uppercase;color:var(--muted);padding:0 3px;white-space:nowrap}
.seg{display:flex;gap:3px;overflow-x:auto;scrollbar-width:none;-webkit-overflow-scrolling:touch}
.seg::-webkit-scrollbar{display:none}
.seg button{
font-family:'Jost',sans-serif;font-size:13px;font-weight:600;color:var(--muted);
background:none;border:none;cursor:pointer;padding:8px 12px;border-radius:9px;transition:.14s;white-space:nowrap;flex:none;
}
.seg button.active{background:linear-gradient(135deg,var(--c1),var(--c2));color:#fff}
.round{
width:36px;height:36px;border-radius:10px;border:1px solid var(--line);background:var(--surface-2);color:var(--text);
cursor:pointer;display:grid;place-items:center;font-family:'Unbounded',sans-serif;font-weight:700;font-size:16px;
transition:background .14s,transform .12s;flex:none;
}
.round:hover{background:var(--surface-3)}
.round:active{transform:scale(.94)}
.round:disabled{opacity:.35;cursor:default}
.octave-val{font-family:'Unbounded',sans-serif;font-weight:700;font-size:13px;min-width:34px;text-align:center;color:var(--c3)}
.toggle{
display:inline-flex;align-items:center;gap:8px;cursor:pointer;font-size:13px;font-weight:600;color:var(--muted);
background:var(--surface);border:1px solid var(--line);border-radius:13px;padding:9px 12px;white-space:nowrap;transition:color .15s;
}
.toggle.on{color:var(--text)}
.dot{width:32px;height:19px;border-radius:99px;background:var(--surface-2);border:1px solid var(--line);position:relative;flex:none;transition:background .2s}
.dot::after{content:"";position:absolute;top:2px;left:2px;width:14px;height:14px;border-radius:50%;background:#fff;transition:transform .2s}
.toggle.on .dot{background:var(--c2);border-color:var(--c2)}
.toggle.on .dot::after{transform:translateX(13px)}
input[type=range]{
-webkit-appearance:none;appearance:none;width:78px;height:5px;border-radius:99px;
background:var(--surface-2);cursor:pointer;outline-offset:4px;
}
input[type=range]::-webkit-slider-thumb{-webkit-appearance:none;width:16px;height:16px;border-radius:50%;background:var(--c2);border:3px solid var(--bg);box-shadow:0 0 0 1px var(--c2);cursor:pointer}
input[type=range]::-moz-range-thumb{width:12px;height:12px;border-radius:50%;background:var(--c2);border:none;cursor:pointer}
.rec-btn{
font-family:'Jost',sans-serif;font-size:13px;font-weight:600;color:var(--text);
background:var(--surface);border:1px solid var(--line);border-radius:13px;padding:9px 13px;cursor:pointer;
display:inline-flex;align-items:center;gap:7px;transition:background .14s,border-color .14s,color .14s;white-space:nowrap;
}
.rec-btn:hover{background:var(--surface-2)}
.rec-btn .rdot{width:9px;height:9px;border-radius:50%;background:currentColor;display:inline-block;flex:none}
.rec-btn.recording{border-color:var(--c1);color:var(--c1)}
.rec-btn.recording .rdot{animation:blink 1s infinite}
@keyframes blink{50%{opacity:.25}}
.rec-btn:disabled{opacity:.35;cursor:default}
/* ================= INFO BAR ================= */
.infobar{flex:none;display:flex;align-items:center;justify-content:center;gap:14px;min-height:30px;flex-wrap:wrap}
.now{
font-family:'Unbounded',sans-serif;font-weight:700;font-size:clamp(15px,2.4vw,20px);
letter-spacing:.04em;color:var(--c3);min-width:56px;text-align:center;
}
.song-hint{
display:none;align-items:center;gap:10px;background:var(--surface);border:1px solid var(--line);
border-radius:99px;padding:6px 14px;font-size:13px;color:var(--muted);max-width:100%;
}
.song-hint.on{display:inline-flex}
.song-hint b{color:var(--c1);font-family:'Unbounded',sans-serif;font-weight:700;font-size:14px}
.song-hint .prog{font-size:12px;font-variant-numeric:tabular-nums}
.song-hint .reset{background:none;border:none;color:var(--muted);cursor:pointer;font-family:'Jost',sans-serif;font-size:12.5px;font-weight:600;text-decoration:underline;padding:0}
.song-hint .reset:hover{color:var(--text)}
/* ================= PIANO ================= */
.piano-shell{
flex:1;min-height:0;
background:linear-gradient(180deg,#221a3a,#160f28);
border:1px solid var(--line);border-radius:18px;
padding:clamp(8px,1.4vh,16px);
box-shadow:0 20px 60px rgba(0,0,0,.5);
display:flex;flex-direction:column;overflow:hidden;
}
.piano{position:relative;flex:1;min-height:110px;width:100%;touch-action:none}
.wkey{
position:absolute;top:0;bottom:0;
background:linear-gradient(180deg,#fbfbff,#e7e4f0);
border:1px solid #c7c2d8;border-radius:0 0 8px 8px;
cursor:pointer;display:flex;flex-direction:column;align-items:center;justify-content:flex-end;
padding-bottom:clamp(5px,1.2vh,11px);gap:3px;
box-shadow:inset 0 -4px 6px rgba(0,0,0,.08);transition:background .05s;
}
.wkey.active{background:linear-gradient(180deg,#e9d6ff,#d3b4ff);box-shadow:inset 0 -6px 14px rgba(160,80,255,.4),0 0 18px rgba(192,77,255,.5)}
.wkey.hint{background:linear-gradient(180deg,#fff0d6,#ffd79a);box-shadow:inset 0 -6px 14px rgba(255,160,40,.35),0 0 18px rgba(255,180,60,.45)}
.bkey{
position:absolute;top:0;z-index:3;height:62%;
background:linear-gradient(180deg,#2a2440,#0e0a1c);
border:1px solid #000;border-radius:0 0 6px 6px;cursor:pointer;
box-shadow:0 4px 8px rgba(0,0,0,.5),inset 0 -3px 4px rgba(255,255,255,.06);transition:background .05s;
}
.bkey.active{background:linear-gradient(180deg,#8b5cf6,#5b2ec4);box-shadow:0 0 18px rgba(139,92,246,.7)}
.bkey.hint{background:linear-gradient(180deg,#ffb020,#c47800);box-shadow:0 0 18px rgba(255,176,32,.6)}
.keylabel{font-size:clamp(9px,1.15vw,12px);font-weight:600;color:#8b83a6;pointer-events:none;line-height:1}
.notename{font-size:clamp(8px,.95vw,10px);color:#b3accb;pointer-events:none;line-height:1}
.bkey .keylabel{position:absolute;bottom:clamp(5px,1vh,9px);left:0;right:0;text-align:center;color:#c9c2e0}
footer{flex:none;text-align:center;font-size:11.5px;color:var(--muted);line-height:1.5}
footer b{color:var(--c3);font-weight:600}
.rotate-tip{display:none;font-size:11px;color:var(--muted)}
.toast{
position:fixed;top:calc(64px + env(safe-area-inset-top));left:50%;transform:translate(-50%,-12px);
background:var(--surface-2);border:1px solid var(--line);border-left:3px solid var(--c1);
color:var(--text);font-size:14px;font-weight:500;padding:11px 18px;border-radius:12px;
box-shadow:0 10px 34px rgba(0,0,0,.5);opacity:0;pointer-events:none;
transition:opacity .25s,transform .25s;z-index:60;max-width:88vw;text-align:center;
}
.toast.show{opacity:1;transform:translate(-50%,0)}
/* ============ RESPONSIVE ============ */
@media (max-width:960px){ .hchip{display:none} }
@media (max-width:760px){
.logo-text span{display:none}
.group .lab{display:none}
.seg button{padding:8px 10px;font-size:12.5px}
.toggle{padding:8px 10px;font-size:12.5px}
.rec-btn{padding:8px 11px;font-size:12.5px}
input[type=range]{width:60px}
.notename{display:none}
footer .long{display:none}
.rotate-tip{display:block}
}
@media (max-width:430px){
.toggle .txt,.rec-btn .txt{display:none}
.group{padding:5px 7px;gap:5px}
.controls{gap:7px}
.round{width:34px;height:34px}
}
/* short screens / landscape phones — maximise the keyboard */
@media (max-height:540px){
header{padding:6px 12px}
.logo-text span{display:none}
.infobar{min-height:22px}
.now{font-size:14px}
footer{display:none}
.wrap{gap:7px;padding-top:7px;padding-bottom:7px}
.piano-shell{padding:7px;border-radius:14px}
}
@media (max-height:430px){
header{display:none}
.keylabel,.notename{display:none}
}
@media (prefers-reduced-motion:reduce){ *{transition:none!important;animation:none!important} }
</style>
</head>
<body>
<header>
<div class="logo">
<div class="logo-mark">S</div>
<div class="logo-text"><b>Stellix Piano</b><span>by Coding Stellix</span></div>
</div>
<div class="spacer"></div>
<div class="hchip">Playable online piano & synth</div>
</header>
<div class="wrap">
<div class="controls">
<div class="group">
<span class="lab">Sound</span>
<div class="seg" id="instrument">
<button class="active" data-ins="piano">Piano</button>
<button data-ins="organ">Organ</button>
<button data-ins="synth">Synth</button>
<button data-ins="bell">Bell</button>
<button data-ins="strings">Strings</button>
<button data-ins="chip">Chip</button>
</div>
</div>
<div class="group">
<span class="lab">Octave</span>
<button class="round" id="octDown" aria-label="Octave down">−</button>
<span class="octave-val" id="octVal">C4</span>
<button class="round" id="octUp" aria-label="Octave up">+</button>
</div>
<div class="group">
<span class="lab">Keys</span>
<button class="round" id="rangeDown" aria-label="Fewer octaves">−</button>
<span class="octave-val" id="rangeVal">2</span>
<button class="round" id="rangeUp" aria-label="More octaves">+</button>
</div>
<div class="group">
<span class="lab">Vol</span>
<input type="range" id="vol" min="0" max="100" value="80" aria-label="Volume">
</div>
<label class="toggle" id="sustainToggle"><span class="dot"></span><span class="txt">Sustain</span></label>
<label class="toggle on" id="labelToggle"><span class="dot"></span><span class="txt">Hints</span></label>
<div class="spacer"></div>
<div class="group">
<span class="lab">Learn</span>
<div class="seg" id="song">
<button class="active" data-song="none">Off</button>
<button data-song="twinkle">Twinkle</button>
<button data-song="birthday">Birthday</button>
<button data-song="odeToJoy">Ode to Joy</button>
</div>
</div>
<button class="rec-btn" id="recBtn"><span class="rdot"></span><span class="txt">Record</span></button>
<button class="rec-btn" id="playBtn" disabled>▶<span class="txt">Play</span></button>
</div>
<div class="infobar">
<div class="now" id="now"> </div>
<div class="song-hint" id="songHint">
Next: <b id="nextNote">C</b>
<span class="prog" id="songProg">0/0</span>
<button class="reset" id="songReset">restart</button>
</div>
</div>
<div class="piano-shell">
<div class="piano" id="piano"></div>
</div>
<footer>
<span class="long">Mouse, touch or keyboard — white keys <b>A S D F G H J K L</b>, black keys <b>W E T Y U O P</b>, hold <b>Space</b> to sustain • </span>
Built with <b>Coding Stellix</b>
<div class="rotate-tip">Tip: rotate your phone sideways for a bigger keyboard</div>
</footer>
</div>
<div class="toast" id="toast" role="status" aria-live="polite"></div>
<script>
(function(){
"use strict";
var $ = function(id){ return document.getElementById(id); };
/* ================= AUDIO ================= */
var actx=null, master=null, volume=0.8;
function ensureAudio(){
if(actx) return;
actx = new (window.AudioContext||window.webkitAudioContext)();
master = actx.createGain();
master.gain.value = volume;
master.connect(actx.destination);
}
function resumeAudio(){ if(actx && actx.state==="suspended") actx.resume(); }
function midiToFreq(m){ return 440*Math.pow(2,(m-69)/12); }
var INSTRUMENTS = {
piano: {type:"triangle", attack:0.005, decay:0.90, sustain:0.00, release:0.25, gain:0.50},
organ: {type:"sine", attack:0.020, decay:0.10, sustain:0.80, release:0.20, gain:0.34, harmonics:[2]},
synth: {type:"sawtooth", attack:0.010, decay:0.20, sustain:0.55, release:0.25, gain:0.28, detune:7},
bell: {type:"sine", attack:0.002, decay:1.40, sustain:0.00, release:0.40, gain:0.50, harmonics:[3]},
strings:{type:"sawtooth", attack:0.160, decay:0.30, sustain:0.70, release:0.55, gain:0.20, detune:5, harmonics:[2]},
chip: {type:"square", attack:0.002, decay:0.16, sustain:0.35, release:0.10, gain:0.20}
};
var instrument="piano", sustainMode=false;
var active={};
function noteOn(midi){
ensureAudio(); resumeAudio();
if(active[midi]) killVoice(midi, true);
var cfg=INSTRUMENTS[instrument], now=actx.currentTime, freq=midiToFreq(midi);
var vgain=actx.createGain();
vgain.gain.setValueAtTime(0.0001, now);
vgain.gain.linearRampToValueAtTime(cfg.gain, now+cfg.attack);
var sustainLevel=Math.max(0.0001, cfg.gain*cfg.sustain);
vgain.gain.setTargetAtTime(sustainLevel, now+cfg.attack, Math.max(0.01,cfg.decay/3));
vgain.connect(master);
var oscs=[];
function addOsc(mult, detuneCents, level){
var o=actx.createOscillator();
o.type=cfg.type;
o.frequency.value=freq*mult;
if(detuneCents) o.detune.value=detuneCents;
if(level!==undefined && level!==1){
var g=actx.createGain(); g.gain.value=level; o.connect(g); g.connect(vgain);
} else { o.connect(vgain); }
o.start(now); oscs.push(o);
}
addOsc(1,0,1);
if(cfg.detune){ addOsc(1,cfg.detune,0.8); addOsc(1,-cfg.detune,0.8); }
if(cfg.harmonics){ cfg.harmonics.forEach(function(h){ addOsc(h,0,0.28); }); }
var voice={vgain:vgain,oscs:oscs,cfg:cfg,timer:null};
active[midi]=voice;
if(cfg.sustain===0){
var life=cfg.attack+cfg.decay+cfg.release;
oscs.forEach(function(o){ try{o.stop(now+life+0.08);}catch(e){} });
voice.timer=setTimeout(function(){
if(active[midi]===voice){ try{vgain.disconnect();}catch(e){} delete active[midi]; }
},(life+0.12)*1000);
}
}
function killVoice(midi, immediate){
var v=active[midi]; if(!v || !actx) return;
var now=actx.currentTime;
var rel=immediate?0.02:v.cfg.release;
try{
v.vgain.gain.cancelScheduledValues(now);
v.vgain.gain.setValueAtTime(Math.max(0.0001,v.vgain.gain.value), now);
v.vgain.gain.exponentialRampToValueAtTime(0.0001, now+rel);
v.oscs.forEach(function(o){ try{o.stop(now+rel+0.03);}catch(e){} });
}catch(e){}
if(v.timer) clearTimeout(v.timer);
var vg=v.vgain;
setTimeout(function(){ try{vg.disconnect();}catch(e){} },(rel+0.12)*1000);
delete active[midi];
}
function noteOff(midi){
var v=active[midi]; if(!v) return;
if(sustainMode) return;
if(v.cfg.sustain===0) return;
killVoice(midi,false);
}
function releaseAllSustained(){
Object.keys(active).forEach(function(m){ if(!down[m]) killVoice(+m,false); });
}
/* ================= LAYOUT DATA ================= */
var WHITE=[0,2,4,5,7,9,11];
var BLACK={1:0,3:1,6:3,8:4,10:5};
var NOTE_NAMES=["C","C#","D","D#","E","F","F#","G","G#","A","A#","B"];
var KEYMAP={a:0,w:1,s:2,e:3,d:4,f:5,t:6,g:7,y:8,h:9,u:10,j:11,k:12,o:13,l:14,p:15,';':16,"'":17};
var REVKEY={}; Object.keys(KEYMAP).forEach(function(k){ REVKEY[KEYMAP[k]]=k; });
var baseOctave=4, octaveSpan=2, showLabels=true;
var keyEls={}, down={};
function baseMidi(){ return 12*(baseOctave+1); }
function autoSpan(){
var w=window.innerWidth, h=window.innerHeight;
var landscapePhone = h<540 && w>h;
if(w<430) return 1;
if(landscapePhone) return 2;
if(w<760) return 2;
if(w<1024) return 2;
return 3;
}
function buildPiano(){
var piano=$('piano');
piano.innerHTML=""; keyEls={};
var totalWhite=WHITE.length*octaveSpan+1;
var whiteW=100/totalWhite;
var idx=0;
for(var oct=0;oct<octaveSpan;oct++){
for(var w=0;w<WHITE.length;w++){ addWhite(baseOctave+oct, WHITE[w], idx); idx++; }
}
addWhite(baseOctave+octaveSpan, 0, idx);
for(var oc=0;oc<octaveSpan;oc++){
for(var bs in BLACK){
addBlack(baseOctave+oc, parseInt(bs,10), oc*WHITE.length+BLACK[bs], whiteW);
}
}
refreshHint();
function addWhite(octave, semi, index){
var midi=12*(octave+1)+semi;
var el=document.createElement('div');
el.className="wkey"; el.dataset.midi=midi;
el.style.left=(index*whiteW)+"%"; el.style.width=whiteW+"%";
var lbl=REVKEY[midi-baseMidi()];
var name=NOTE_NAMES[midi%12]+(Math.floor(midi/12)-1);
var html="";
if(showLabels && lbl) html+='<span class="keylabel">'+lbl.toUpperCase()+'</span>';
html+='<span class="notename">'+name+'</span>';
el.innerHTML=html;
piano.appendChild(el); keyEls[midi]=el;
}
function addBlack(octave, semi, precedingWhite, whiteWpct){
var midi=12*(octave+1)+semi;
var el=document.createElement('div');
el.className="bkey"; el.dataset.midi=midi;
var bw=whiteWpct*0.62;
el.style.width=bw+"%";
el.style.left=((precedingWhite+1)*whiteWpct-bw/2)+"%";
var lbl=REVKEY[midi-baseMidi()];
if(showLabels && lbl) el.innerHTML='<span class="keylabel">'+lbl.toUpperCase()+'</span>';
piano.appendChild(el); keyEls[midi]=el;
}
}
/* ================= PRESS / RELEASE ================= */
function press(midi){
if(down[midi]) return; down[midi]=true;
noteOn(midi);
if(keyEls[midi]) keyEls[midi].classList.add('active');
showNote(midi);
if(recording) recEvent(midi,true);
checkSong(midi);
}
function release(midi){
if(!down[midi]) return; delete down[midi];
noteOff(midi);
if(keyEls[midi]) keyEls[midi].classList.remove('active');
if(recording) recEvent(midi,false);
}
function releaseAll(){ Object.keys(down).forEach(function(m){ release(+m); }); }
function showNote(midi){
$('now').textContent=NOTE_NAMES[midi%12]+(Math.floor(midi/12)-1);
clearTimeout(showNote._t);
showNote._t=setTimeout(function(){ $('now').innerHTML=" "; },800);
}
/* ---- pointer handling: multi-touch + slide across keys ---- */
var pianoEl=$('piano');
var pointerNote={};
function midiFromPoint(x,y){
var el=document.elementFromPoint(x,y);
if(!el) return null;
if(!el.classList.contains('wkey') && !el.classList.contains('bkey')){
el = el.closest ? el.closest('.wkey,.bkey') : null;
}
return el ? +el.dataset.midi : null;
}
function heldElsewhere(midi, exceptId){
for(var pid in pointerNote){ if(String(pid)!==String(exceptId) && pointerNote[pid]===midi) return true; }
return false;
}
pianoEl.addEventListener('pointerdown', function(e){
var m=midiFromPoint(e.clientX,e.clientY);
if(m===null) return;
e.preventDefault();
try{ pianoEl.setPointerCapture(e.pointerId); }catch(err){}
pointerNote[e.pointerId]=m; press(m);
});
pianoEl.addEventListener('pointermove', function(e){
if(!(e.pointerId in pointerNote)) return;
var m=midiFromPoint(e.clientX,e.clientY);
var prev=pointerNote[e.pointerId];
if(m!==prev){
if(prev!==null && prev!==undefined && !heldElsewhere(prev,e.pointerId)) release(prev);
pointerNote[e.pointerId]=m;
if(m!==null) press(m);
}
});
function endPointer(e){
if(!(e.pointerId in pointerNote)) return;
var m=pointerNote[e.pointerId];
delete pointerNote[e.pointerId];
if(m!==null && m!==undefined && !heldElsewhere(m,null)) release(m);
}
window.addEventListener('pointerup', endPointer);
window.addEventListener('pointercancel', endPointer);
pianoEl.addEventListener('contextmenu', function(e){ e.preventDefault(); });
/* ---- computer keyboard ---- */
window.addEventListener('keydown', function(e){
if(e.repeat || e.metaKey || e.ctrlKey) return;
var k=e.key.toLowerCase();
if(k===" "){ e.preventDefault(); setSustain(true); return; }
if(KEYMAP.hasOwnProperty(k)){ e.preventDefault(); press(baseMidi()+KEYMAP[k]); }
});
window.addEventListener('keyup', function(e){
var k=e.key.toLowerCase();
if(k===" "){ e.preventDefault(); setSustain(false); return; }
if(KEYMAP.hasOwnProperty(k)){ e.preventDefault(); release(baseMidi()+KEYMAP[k]); }
});
window.addEventListener('blur', releaseAll);
/* ================= CONTROLS ================= */
$('instrument').addEventListener('click', function(e){
var b=e.target.closest('button'); if(!b) return;
this.querySelectorAll('button').forEach(function(x){x.classList.remove('active');});
b.classList.add('active'); instrument=b.dataset.ins;
});
function setOctave(v){
var maxBase=7-octaveSpan;
baseOctave=Math.max(1,Math.min(maxBase,v));
$('octVal').textContent="C"+baseOctave;
$('octDown').disabled = baseOctave<=1;
$('octUp').disabled = baseOctave>=maxBase;
releaseAll(); buildPiano();
}
function setSpan(v){
octaveSpan=Math.max(1,Math.min(4,v));
$('rangeVal').textContent=octaveSpan;
$('rangeDown').disabled=octaveSpan<=1;
$('rangeUp').disabled=octaveSpan>=4;
setOctave(baseOctave);
}
$('octUp').addEventListener('click', function(){ setOctave(baseOctave+1); });
$('octDown').addEventListener('click', function(){ setOctave(baseOctave-1); });
$('rangeUp').addEventListener('click', function(){ setSpan(octaveSpan+1); });
$('rangeDown').addEventListener('click', function(){ setSpan(octaveSpan-1); });
$('labelToggle').addEventListener('click', function(){
showLabels=!showLabels; this.classList.toggle('on',showLabels); buildPiano();
});
function setSustain(on){
sustainMode=on;
$('sustainToggle').classList.toggle('on',on);
if(!on) releaseAllSustained();
}
$('sustainToggle').addEventListener('click', function(){ setSustain(!sustainMode); });
$('vol').addEventListener('input', function(){
volume=+this.value/100;
if(master) master.gain.value=volume;
});
/* ================= LEARN A SONG ================= */
var SONGS = {
twinkle: {name:"Twinkle Twinkle", notes:[0,0,7,7,9,9,7, 5,5,4,4,2,2,0, 7,7,5,5,4,4,2, 7,7,5,5,4,4,2, 0,0,7,7,9,9,7, 5,5,4,4,2,2,0]},
birthday: {name:"Happy Birthday", notes:[0,0,2,0,5,4, 0,0,2,0,7,5, 0,0,12,9,5,4,2, 10,10,9,5,7,5]},
odeToJoy: {name:"Ode to Joy", notes:[4,4,5,7,7,5,4,2, 0,0,2,4,4,2,2, 4,4,5,7,7,5,4,2, 0,0,2,4,2,0,0]}
};
var song=null, songPos=0;
$('song').addEventListener('click', function(e){
var b=e.target.closest('button'); if(!b) return;
this.querySelectorAll('button').forEach(function(x){x.classList.remove('active');});
b.classList.add('active');
var key=b.dataset.song;
if(key==="none"){ song=null; $('songHint').classList.remove('on'); refreshHint(); return; }
song=SONGS[key]; songPos=0;
$('songHint').classList.add('on');
// make sure the keyboard is wide enough for this song
var needed=Math.ceil((Math.max.apply(null,song.notes)+1)/12);
if(octaveSpan<needed){ lastSpan=needed; setSpan(needed); }
refreshHint();
toast("Follow the glowing keys — "+song.name);
});
$('songReset').addEventListener('click', function(){ songPos=0; refreshHint(); });
function refreshHint(){
for(var m in keyEls) keyEls[m].classList.remove('hint');
if(!song) return;
if(songPos>=song.notes.length){
$('nextNote').textContent="done ✓";
$('songProg').textContent=song.notes.length+"/"+song.notes.length;
return;
}
var midi=baseMidi()+song.notes[songPos];
$('nextNote').textContent=NOTE_NAMES[midi%12]+(Math.floor(midi/12)-1);
$('songProg').textContent=songPos+"/"+song.notes.length;
if(keyEls[midi]) keyEls[midi].classList.add('hint');
}
function checkSong(midi){
if(!song || songPos>=song.notes.length) return;
if(midi === baseMidi()+song.notes[songPos]){
songPos++;
refreshHint();
if(songPos>=song.notes.length) toast("🎉 You played "+song.name+"!");
}
}
/* ================= RECORD & PLAY ================= */
var recording=false, recData=[], recStart=0, playTimers=[];
function recEvent(midi,on){ recData.push({t:performance.now()-recStart, midi:midi, on:on}); }
$('recBtn').addEventListener('click', function(){
var txt=this.querySelector('.txt');
if(!recording){
recording=true; recData=[]; recStart=performance.now();
this.classList.add('recording'); if(txt) txt.textContent="Stop";
$('playBtn').disabled=true;
toast("Recording…");
} else {
recording=false; this.classList.remove('recording'); if(txt) txt.textContent="Record";
$('playBtn').disabled = recData.length===0;
toast(recData.length ? "Recorded — press Play" : "Nothing recorded");
}
});
$('playBtn').addEventListener('click', function(){
if(!recData.length || recording) return;
playTimers.forEach(clearTimeout); playTimers=[];
releaseAll();
recData.forEach(function(ev){
playTimers.push(setTimeout(function(){
if(ev.on) press(ev.midi); else release(ev.midi);
}, ev.t));
});
toast("Playing back");
});
/* ================= TOAST ================= */
var toastEl=$('toast'), toastTimer=null;
function toast(msg){
toastEl.textContent=msg; toastEl.classList.add('show');
clearTimeout(toastTimer);
toastTimer=setTimeout(function(){ toastEl.classList.remove('show'); },1700);
}
/* ================= RESPONSIVE ================= */
var lastSpan=null;
function fit(){
var s=autoSpan();
if(s!==lastSpan){ lastSpan=s; setSpan(s); }
}
var rt=null;
window.addEventListener('resize', function(){
clearTimeout(rt);
rt=setTimeout(function(){ releaseAll(); fit(); },150);
});
window.addEventListener('orientationchange', function(){
setTimeout(function(){ releaseAll(); lastSpan=null; fit(); },260);
});
/* ================= BOOT ================= */
fit();
setOctave(4);
['pointerdown','keydown'].forEach(function(ev){
window.addEventListener(ev, function once(){ ensureAudio(); resumeAudio(); window.removeEventListener(ev, once); });
});
})();
</script>
</body>
</html>



