import type { NoteInputEvent } from './webmidi.ts'; // Typing-keyboard piano: the a..' row plays white keys from C, the row // above plays the black keys, z/x shift the octave. Active only when // enabled and when focus is not in a text field. const KEY_TO_SEMITONE: Record = { 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, }; type Listener = (e: NoteInputEvent) => void; class KeyboardPiano { enabled = false; octave = 4; // 'a' plays C4 by default (MIDI 60 at octave 4) private listeners = new Set(); private stateListeners = new Set<() => void>(); private held = new Map(); private attached = false; subscribe(cb: Listener): () => void { this.listeners.add(cb); return () => this.listeners.delete(cb); } subscribeState(cb: () => void): () => void { this.stateListeners.add(cb); return () => this.stateListeners.delete(cb); } private emitState() { for (const cb of this.stateListeners) cb(); } setEnabled(v: boolean) { this.enabled = v; if (!v) this.releaseAll(); this.attach(); this.emitState(); } private attach() { if (this.attached) return; this.attached = true; window.addEventListener('keydown', this.onKeyDown); window.addEventListener('keyup', this.onKeyUp); window.addEventListener('blur', () => this.releaseAll()); } private inTextField(): boolean { const el = document.activeElement; return !!el && (el.tagName === 'INPUT' || el.tagName === 'TEXTAREA' || (el as HTMLElement).isContentEditable); } private onKeyDown = (ev: KeyboardEvent) => { if (!this.enabled || ev.repeat || ev.ctrlKey || ev.metaKey || ev.altKey || this.inTextField()) return; const key = ev.key.toLowerCase(); if (key === 'z') { this.octave = Math.max(0, this.octave - 1); this.emitState(); return; } if (key === 'x') { this.octave = Math.min(8, this.octave + 1); this.emitState(); return; } const semi = KEY_TO_SEMITONE[key]; if (semi === undefined || this.held.has(key)) return; const pitch = (this.octave + 1) * 12 + semi; if (pitch > 127) return; ev.preventDefault(); this.held.set(key, pitch); this.emit({ on: true, pitch, velocity: 100, source: 'keyboard' }); }; private onKeyUp = (ev: KeyboardEvent) => { const key = ev.key.toLowerCase(); const pitch = this.held.get(key); if (pitch === undefined) return; this.held.delete(key); this.emit({ on: false, pitch, velocity: 0, source: 'keyboard' }); }; private releaseAll() { for (const [key, pitch] of this.held) { void key; this.emit({ on: false, pitch, velocity: 0, source: 'keyboard' }); } this.held.clear(); } private emit(e: NoteInputEvent) { for (const cb of this.listeners) cb(e); } } export const keyboardPiano = new KeyboardPiano();