/ proof-of-concept / midiboot
Sign in
proof-of-concept / midiboot
midiboot / src / input / keyboardPiano.ts
89 lines · 2.8 KBBlameHistoryRaw
1import type { NoteInputEvent } from './webmidi.ts';
3// Typing-keyboard piano: the a..' row plays white keys from C, the row
4// above plays the black keys, z/x shift the octave. Active only when
5// enabled and when focus is not in a text field.
7const KEY_TO_SEMITONE: Record<string, number> = {
8 a: 0, w: 1, s: 2, e: 3, d: 4, f: 5, t: 6, g: 7, y: 8, h: 9, u: 10, j: 11,
9 k: 12, o: 13, l: 14, p: 15, ';': 16, "'": 17,
10};
12type Listener = (e: NoteInputEvent) => void;
14class KeyboardPiano {
15 enabled = false;
16 octave = 4; // 'a' plays C4 by default (MIDI 60 at octave 4)
17 private listeners = new Set<Listener>();
18 private stateListeners = new Set<() => void>();
19 private held = new Map<string, number>();
20 private attached = false;
22 subscribe(cb: Listener): () => void {
23 this.listeners.add(cb);
24 return () => this.listeners.delete(cb);
25 }
27 subscribeState(cb: () => void): () => void {
28 this.stateListeners.add(cb);
29 return () => this.stateListeners.delete(cb);
30 }
32 private emitState() { for (const cb of this.stateListeners) cb(); }
34 setEnabled(v: boolean) {
35 this.enabled = v;
36 if (!v) this.releaseAll();
37 this.attach();
38 this.emitState();
39 }
41 private attach() {
42 if (this.attached) return;
43 this.attached = true;
44 window.addEventListener('keydown', this.onKeyDown);
45 window.addEventListener('keyup', this.onKeyUp);
46 window.addEventListener('blur', () => this.releaseAll());
47 }
49 private inTextField(): boolean {
50 const el = document.activeElement;
51 return !!el && (el.tagName === 'INPUT' || el.tagName === 'TEXTAREA' || (el as HTMLElement).isContentEditable);
52 }
54 private onKeyDown = (ev: KeyboardEvent) => {
55 if (!this.enabled || ev.repeat || ev.ctrlKey || ev.metaKey || ev.altKey || this.inTextField()) return;
56 const key = ev.key.toLowerCase();
57 if (key === 'z') { this.octave = Math.max(0, this.octave - 1); this.emitState(); return; }
58 if (key === 'x') { this.octave = Math.min(8, this.octave + 1); this.emitState(); return; }
59 const semi = KEY_TO_SEMITONE[key];
60 if (semi === undefined || this.held.has(key)) return;
61 const pitch = (this.octave + 1) * 12 + semi;
62 if (pitch > 127) return;
63 ev.preventDefault();
64 this.held.set(key, pitch);
65 this.emit({ on: true, pitch, velocity: 100, source: 'keyboard' });
66 };
68 private onKeyUp = (ev: KeyboardEvent) => {
69 const key = ev.key.toLowerCase();
70 const pitch = this.held.get(key);
71 if (pitch === undefined) return;
72 this.held.delete(key);
73 this.emit({ on: false, pitch, velocity: 0, source: 'keyboard' });
74 };
76 private releaseAll() {
77 for (const [key, pitch] of this.held) {
78 void key;
79 this.emit({ on: false, pitch, velocity: 0, source: 'keyboard' });
80 }
81 this.held.clear();
82 }
84 private emit(e: NoteInputEvent) {
85 for (const cb of this.listeners) cb(e);
86 }
89export const keyboardPiano = new KeyboardPiano();
↑↓ move↵openescclose