/ proof-of-concept / midiboot
Sign in
proof-of-concept / midiboot
midiboot / src / components / App.tsx
142 lines · 4.9 KBCodeBlameHistory
0ad1ec1midiboot: in-browser MIDI workstation with pluggable instrumentsJeremy Magland 1import { useEffect, useRef } from 'react';
2import { store, useApp } from '../state/store.ts';
3import { player } from '../audio/player.ts';
4import { midiManager, type NoteInputEvent } from '../input/webmidi.ts';
5import { keyboardPiano } from '../input/keyboardPiano.ts';
6import { writeMidi } from '../midi/write.ts';
7import type { LiveVoice } from '../instruments/types.ts';
8import { DeviceBar } from './DeviceBar.tsx';
9import { Transport } from './Transport.tsx';
10import { TrackList } from './TrackList.tsx';
11import { PianoRoll } from './PianoRoll.tsx';
12import { Library } from './Library.tsx';
14function download(name: string, data: Uint8Array, mime: string) {
15 const blob = new Blob([data as BlobPart], { type: mime });
16 const url = URL.createObjectURL(blob);
17 const a = document.createElement('a');
18 a.href = url;
19 a.download = name;
20 a.click();
21 setTimeout(() => URL.revokeObjectURL(url), 5000);
24function inTextField(): boolean {
25 const el = document.activeElement;
26 return !!el && (el.tagName === 'INPUT' || el.tagName === 'TEXTAREA' || (el as HTMLElement).isContentEditable);
29export function App() {
30 const app = useApp();
31 const fileRef = useRef<HTMLInputElement | null>(null);
32 const voices = useRef(new Map<string, LiveVoice>());
34 // Route note input (any MIDI device, or the typing keyboard) to live
35 // monitoring on the armed/active track, and to the recorder while
36 // recording.
37 useEffect(() => {
38 const handle = (e: NoteInputEvent) => {
39 const st = store.state;
40 const song = st.rec?.song;
41 if (!song) return;
42 const targetId = st.armedTrackId ?? st.activeTrackId;
43 const track = song.tracks.find((t) => t.id === targetId);
44 const key = `${e.source}:${e.pitch}`;
45 if (e.on) {
46 voices.current.get(key)?.cut();
47 if (track) {
48 try {
49 voices.current.set(key, player.previewNoteOn(track.instrumentId, e.pitch, e.velocity));
50 } catch { /* audio blocked until a user gesture */ }
51 }
52 } else {
53 voices.current.get(key)?.noteOff();
54 voices.current.delete(key);
55 }
56 if (player.state === 'recording') store.recorder.noteEvent(e, player.getPositionSec());
57 };
58 const u1 = midiManager.subscribe(handle);
59 const u2 = keyboardPiano.subscribe(handle);
60 void midiManager.request();
61 return () => { u1(); u2(); };
62 }, []);
64 // Global shortcuts.
65 useEffect(() => {
66 const onKey = (ev: KeyboardEvent) => {
67 if (inTextField()) return;
68 const mod = ev.ctrlKey || ev.metaKey;
69 if (ev.code === 'Space') {
70 ev.preventDefault();
71 void store.togglePlay();
72 } else if (mod && !ev.shiftKey && ev.key.toLowerCase() === 'z') {
73 ev.preventDefault();
74 store.undo();
75 } else if ((mod && ev.shiftKey && ev.key.toLowerCase() === 'z') || (mod && ev.key.toLowerCase() === 'y')) {
76 ev.preventDefault();
77 store.redo();
78 } else if (ev.key === 'Delete' || ev.key === 'Backspace') {
79 ev.preventDefault();
80 store.deleteSelection();
81 } else if (ev.key === 'Escape') {
82 store.setLibraryOpen(false);
83 store.setSelection([]);
84 }
85 };
86 window.addEventListener('keydown', onKey);
87 return () => window.removeEventListener('keydown', onKey);
88 }, []);
90 if (!app.ready) return <div className="loading">loading…</div>;
91 const song = app.rec?.song ?? null;
93 return (
94 <div className="app">
95 <header>
96 <span className="brand">midiboot</span>
97 <input
98 className="songname"
99 value={app.rec?.name ?? ''}
100 onChange={(e) => store.renameSong(e.target.value)}
101 title="Song name"
102 />
103 <button onClick={() => store.setLibraryOpen(true)}>Library</button>
104 <button onClick={() => fileRef.current?.click()}>Import MIDI</button>
105 <input
106 ref={fileRef}
107 type="file"
108 accept=".mid,.midi,audio/midi"
109 hidden
110 onChange={async (e) => {
111 const f = e.target.files?.[0];
112 if (f) await store.importMidi(f.name, new Uint8Array(await f.arrayBuffer()));
113 e.target.value = '';
114 }}
115 />
116 <button
117 disabled={!song}
118 onClick={() => {
119 if (song) download(`${app.rec?.name ?? 'song'}.mid`, writeMidi(song), 'audio/midi');
120 }}
121 >Export MIDI</button>
122 <button
123 disabled={!song}
124 onClick={async () => {
125 if (!song) return;
126 const wav = await player.exportWav(song);
127 download(`${app.rec?.name ?? 'song'}.wav`, wav, 'audio/wav');
128 }}
129 >Export WAV</button>
130 <span className="spacer" />
131 <DeviceBar />
132 </header>
133 <Transport />
134 <div className="main">
135 <TrackList />
136 <PianoRoll />
137 </div>
138 <footer className="statusbar">{app.status || 'click in the grid to add notes; right-click deletes; shift+drag selects; space plays'}</footer>
139 <Library />
140 </div>
141 );
↑↓ move↵openescclose