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