/ proof-of-concept / midiboot
Sign in
proof-of-concept / midiboot
midiboot / src / input / webmidi.ts
86 lines · 2.7 KBBlameHistoryRaw
1// Web MIDI input manager: enumerates inputs, follows hot-plug through
2// statechange, and forwards note on/off events from the selected input
3// (or from every input) to subscribers.
5export interface NoteInputEvent {
6 on: boolean;
7 pitch: number;
8 velocity: number; // 1..127 for on
9 source: string;
12export interface MidiState {
13 supported: boolean;
14 requested: boolean;
15 error: string | null;
16 inputs: { id: string; name: string }[];
17 selectedId: string; // 'all' or an input id
20type Listener = (e: NoteInputEvent) => void;
22class MidiManager {
23 private access: MIDIAccess | null = null;
24 private listeners = new Set<Listener>();
25 private stateListeners = new Set<() => void>();
26 private state: MidiState = { supported: typeof navigator !== 'undefined' && 'requestMIDIAccess' in navigator, requested: false, error: null, inputs: [], selectedId: 'all' };
28 getState(): MidiState { return this.state; }
30 subscribeState(cb: () => void): () => void {
31 this.stateListeners.add(cb);
32 return () => this.stateListeners.delete(cb);
33 }
35 subscribe(cb: Listener): () => void {
36 this.listeners.add(cb);
37 return () => this.listeners.delete(cb);
38 }
40 private setState(patch: Partial<MidiState>) {
41 this.state = { ...this.state, ...patch };
42 for (const cb of this.stateListeners) cb();
43 }
45 async request(): Promise<void> {
46 if (!this.state.supported || this.access) return;
47 this.setState({ requested: true });
48 try {
49 this.access = await navigator.requestMIDIAccess({ sysex: false });
50 this.access.onstatechange = () => this.refresh();
51 this.refresh();
52 } catch (err) {
53 this.setState({ error: err instanceof Error ? err.message : String(err) });
54 }
55 }
57 select(id: string) {
58 this.setState({ selectedId: id });
59 this.refresh();
60 }
62 private refresh() {
63 if (!this.access) return;
64 const inputs: { id: string; name: string }[] = [];
65 for (const input of this.access.inputs.values()) {
66 inputs.push({ id: input.id, name: input.name ?? input.id });
67 const active = this.state.selectedId === 'all' || this.state.selectedId === input.id;
68 input.onmidimessage = active ? (ev: MIDIMessageEvent) => this.onMessage(input.name ?? input.id, ev) : null;
69 }
70 this.setState({ inputs });
71 }
73 private onMessage(source: string, ev: MIDIMessageEvent) {
74 const d = ev.data;
75 if (!d || d.length < 3) return;
76 const kind = d[0] >> 4;
77 if (kind === 0x9 && d[2] > 0) this.emit({ on: true, pitch: d[1], velocity: d[2], source });
78 else if (kind === 0x8 || (kind === 0x9 && d[2] === 0)) this.emit({ on: false, pitch: d[1], velocity: 0, source });
79 }
81 private emit(e: NoteInputEvent) {
82 for (const cb of this.listeners) cb(e);
83 }
86export const midiManager = new MidiManager();
↑↓ move↵openescclose