/ proof-of-concept / midiboot
Sign in
proof-of-concept / midiboot
midiboot / src / components / App.tsx
170 lines · 6.3 KBBlameHistoryRaw
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 (mod && ev.key.toLowerCase() === 'a') {
82 ev.preventDefault();
83 store.selectAll();
84 } else if (mod && ev.key.toLowerCase() === 'c') {
85 ev.preventDefault();
86 store.copySelection();
87 } else if (mod && ev.key.toLowerCase() === 'x') {
88 ev.preventDefault();
89 store.cutSelection();
90 } else if (mod && ev.key.toLowerCase() === 'v') {
91 ev.preventDefault();
92 store.pasteAtPlayhead();
93 } else if (mod && ev.key.toLowerCase() === 'd') {
94 ev.preventDefault();
95 store.duplicateSelection();
96 } else if (ev.key.startsWith('Arrow') && store.state.selection.length > 0) {
97 ev.preventDefault();
98 const song = store.song();
99 if (!song) return;
100 const grid = store.snapTicks() || Math.round(song.ppq / 8);
101 if (ev.key === 'ArrowLeft') store.nudgeSelection(-grid, 0);
102 else if (ev.key === 'ArrowRight') store.nudgeSelection(grid, 0);
103 else if (ev.key === 'ArrowUp') store.nudgeSelection(0, ev.shiftKey ? 12 : 1);
104 else if (ev.key === 'ArrowDown') store.nudgeSelection(0, ev.shiftKey ? -12 : -1);
105 } else if (ev.key === 'Escape') {
106 store.setLibraryOpen(false);
107 store.setSelection([]);
108 }
109 };
110 window.addEventListener('keydown', onKey);
111 return () => window.removeEventListener('keydown', onKey);
112 }, []);
114 if (!app.ready) return <div className="loading">loading…</div>;
115 const song = app.rec?.song ?? null;
117 return (
118 <div className="app">
119 <header>
120 <span className="brand">midiboot</span>
121 <input
122 className="songname"
123 value={app.rec?.name ?? ''}
124 onChange={(e) => store.renameSong(e.target.value)}
125 title="Song name"
126 />
127 <button onClick={() => store.setLibraryOpen(true)}>Library</button>
128 <button onClick={() => fileRef.current?.click()}>Import MIDI</button>
129 <input
130 ref={fileRef}
131 type="file"
132 accept=".mid,.midi,audio/midi"
133 hidden
134 onChange={async (e) => {
135 const f = e.target.files?.[0];
136 if (f) await store.importMidi(f.name, new Uint8Array(await f.arrayBuffer()));
137 e.target.value = '';
138 }}
139 />
140 <button
141 disabled={!song}
142 onClick={() => {
143 if (song) download(`${app.rec?.name ?? 'song'}.mid`, writeMidi(song), 'audio/midi');
144 }}
145 >Export MIDI</button>
146 <button
147 disabled={!song}
148 onClick={async () => {
149 if (!song) return;
150 try {
151 const wav = await player.exportWav(song);
152 download(`${app.rec?.name ?? 'song'}.wav`, wav, 'audio/wav');
153 } catch (err) {
154 store.setStatus(`Export failed: ${err instanceof Error ? err.message : String(err)}`);
155 }
156 }}
157 >Export WAV</button>
158 <span className="spacer" />
159 <DeviceBar />
160 </header>
161 <Transport />
162 <div className="main">
163 <TrackList />
164 <PianoRoll />
165 </div>
166 <footer className="statusbar">{app.status || 'click sets the position; drag selects; double-click adds a note; right-click deletes; ctrl+C/X/V/D and arrows act on the selection; space plays'}</footer>
167 <Library />
168 </div>
169 );
↑↓ move↵openescclose