A browser Indian harmonium. Dark UI, 39 playable reed keys (23 white, 16 black) covering C3–D6 (MIDI 48–86), Sargam labels over a movable Sa, JSON score playback, and in-page WebMCP tools for agents.
One audio engine (playNote(key) / stopNote(key)) drives everything: mouse, computer keyboard, JSON score clock, and WebMCP. Keys light up exactly when they sound.
** agent plays music:
Play by hand — mouse or computer keyboard, keys light as they sound:
npm install
npm run devOpen the printed URL (Vite defaults to http://localhost:5173), then tap “Tap to start” — Chrome blocks audio until a user gesture. On that tap the app calls Tone.start() / AudioContext.resume(), loads harmonium reed samples, and the instrument is playable.
Production build:
npm run build
npm run previewMouse/touch — pointer down/up on any key.
Computer keyboard — a slice of the keyboard around C4 (key 12):
| Computer keys | Notes |
|---|---|
| A S D F G H J K L | white keys from the current octave base |
| W E T Y U | black keys (C#, D#, F#, G#, A#) |
| Z or [ | octave down |
| X or ] | octave up |
| Space | play / stop the loaded score (ignored while typing in text fields) |
Auto-repeat is ignored, and typing in the JSON textarea never plays notes.
Sargam / movable Sa — every key shows a Western name and a Sargam name relative to Sa (default Sa = C). On the keyboard, komal swaras display as lowercase (re, ga, dha, ni); natural swaras stay capitalized (Re, Ga, Dha, Ni). Canonical names for WebMCP and score JSON are unchanged (e.g. komal Re, Re). Changing Sa relabels only — frequencies never move (key 12 is always C4).
| Key (Sa = C) | Western | Keyboard label | WebMCP / score name |
|---|---|---|---|
| C | C | Sa | Sa |
| C# | C# | re | komal Re |
| D | D | Re | Re |
| D# | D# | ga | komal Ga |
| E | E | Ga | Ga |
| F | F | Ma | Ma |
| F# | F# | tivra Ma | tivra Ma |
| G | G | Pa | Pa |
| G# | G# | dha | komal Dha |
| A | A | Dha | Dha |
| A# | A# | ni | komal Ni |
| B | B | Ni | Ni |
Edit JSON (top bar) opens a simplified standalone editor tab: it starts with the current score, has a Load demo button (a short Sa–Re–Ga–Ma–Pa–Dha–Ni–Sa' phrase), and Apply to harmonium posts the edited JSON back to the main page, which validates it before loading. Export JSON downloads the current score as harmonium-score.json. The round ▶ / ❚❚ button plays or stops the loaded score.
Shape:
{
"sa": "C",
"events": [
{ "t": 0.0, "key": 12, "note": "Sa", "dur": 0.5 },
{ "t": 0.5, "key": 14, "note": "Re", "dur": 0.5 }
]
}t— start time in seconds from playback startkey— 0–38, the source of truth for pitch; ifnotedisagrees,keywins and nothing crashesnote— Sargam label (advisory)dur— duration in seconds; overlapping events are chordssa— updates the page’s Sa for labels before playing
The playback clock is the Web Audio clock (Tone.Transport scheduling), so keys light in sync with sound, not setTimeout drift.
This is a normal website — no server, no service worker. If the browser exposes the WebMCP page API (document.modelContext.registerTool, with navigator.modelContext as deprecated fallback), the page registers five tools that drive the same visible keyboard:
| Tool | Input | Returns |
|---|---|---|
play_note |
{ key?: 0-38, note?: Sargam, dur?: seconds } (key or note required) |
{ ok, tool, key, note, western, dur, sa } or { ok: false, tool, error }. Auto-releases after dur (default 2 s). |
play_score |
{ sa?, events: [{ t, key, note?, dur }] } (events ≥ 1) |
{ ok, tool, events, durationSeconds, sa } or error shape. Honors the abort signal. |
set_sa |
{ sa: "C"…"B" } (12 Western names) |
{ ok, tool, sa, relabeled, retuned: false }. Relabels only; never retunes. |
stop |
{} |
{ ok, tool, stopped: true }. Stops all reeds and score playback. |
get_state |
{} |
{ ok, tool, sa, audioUnlocked, audioSource, activeKeys, scorePlaying } (readOnlyHint). |
Audio lock: browsers require a user gesture for sound. If audio is locked, play_note and play_score return { ok: false, error } asking the human to tap “Tap to start” first — WebMCP execute is not a user gesture. Every tool returns { ok, tool, ... }; errors carry an error message.
Enabling WebMCP for local testing: Chrome needs chrome://flags/#enable-webmcp-testing enabled (then relaunch), or use a browser/agent with WebMCP support. Without it, the page shows “WebMCP unavailable” and the human instrument still works fully.
- Preferred:
Tone.Samplerwith real harmonium reed recordings, chromatic anchors C3–D5 inpublic/samples/harmonium/; the sampler retunes the nearest anchor for the rest of the C3–D6 range. The upstream pack has no recordings above D5 and none for F#4, so 13 keys (F#4, D#5–D6) always sound retuned — an asset limit, not a bug. - Fallback: if samples fail to load, a sustained reed-like
Tone.PolySynth(detuned saw stack through a lowpass — not a percussive piano ping). The app always makes sound.
Harmonium samples from the tonejs-instruments sample library by Makefully Studios, licensed CC-BY 3.0. See public/samples/harmonium/README.md. The app code itself is MIT (see LICENSE); sample licensing is separate.
Vite + React + TypeScript + Tone.js. No secrets, no backend, no service workers.
npm run typecheck # tsc --noEmit
npm test # pitch + score unit tests (node:test via tsx)
npm run check # 39/23/16 key layout assertions
npm run build # typecheck + production bundle
