0ad1ec1midiboot: in-browser MIDI workstation with pluggable instrumentsJeremy Magland 1import { useMemo } from 'react';
2import { store, useApp, usePlayerVersion } from '../state/store.ts';
3import { player } from '../audio/player.ts';
4import { TempoMap } from '../midi/time.ts';
6const SNAP_OPTIONS: { label: string; beats: number }[] = [
7 { label: 'snap off', beats: 0 },
8 { label: '1 bar', beats: 4 },
9 { label: '1/2', beats: 2 },
10 { label: '1/4', beats: 1 },
11 { label: '1/8', beats: 0.5 },
12 { label: '1/16', beats: 0.25 },
13 { label: '1/32', beats: 0.125 },
14];
16export function Transport() {
17 const app = useApp();
18 usePlayerVersion();
19 const song = app.rec?.song ?? null;
20 const tm = useMemo(() => (song ? new TempoMap(song.ppq, song.tempo, song.timeSig) : null), [song]);
22 if (!song || !tm) return <div className="transport" />;
24 const pos = Math.max(0, player.getPositionSec());
25 const tick = tm.secToTick(pos);
26 const { bar, beat } = tm.barBeatAt(tick);
27 const mm = Math.floor(pos / 60);
28 const ss = (pos % 60).toFixed(1).padStart(4, '0');
29 const singleTempo = song.tempo.length === 1;
30 const bpm = Math.round(tm.bpmAt(tick));
31 const state = player.state;
33 return (
34 <div className="transport">
35 <button title="Go to start" onClick={() => store.seekTo(0)}>|◀</button>
36 <button
37 className={state === 'playing' ? 'toggled' : ''}
38 title="Play/stop (space)"
39 onClick={() => void store.togglePlay()}
40 disabled={player.preparing}
41 >
42 {state !== 'stopped' ? '◼' : '▶'}
43 </button>
44 <button
45 className={`rec ${state === 'recording' ? 'toggled' : ''}`}
46 title="Record into the armed track, with a one-bar count-in"
47 onClick={() => (state === 'recording' ? store.stopTransport() : void store.record())}
48 disabled={player.preparing}
49 >
50 ●
51 </button>
52 <span className="pos" title="bar.beat">{bar}.{beat}</span>
53 <span className="pos sec">{mm}:{ss}</span>
54 {player.preparing && <span className="status">rendering…</span>}
55 <span className="spacer" />
56 <label title={singleTempo ? 'Tempo (bpm)' : 'This song has a tempo map; editing it as one bpm is not supported yet'}>
57 bpm{' '}
58 <input
59 type="number"
60 min={20}
61 max={300}
62 value={bpm}
63 disabled={!singleTempo}
64 onChange={(e) => {
65 const v = Math.max(20, Math.min(300, Number(e.target.value) || 120));
66 store.mutateSong((s) => { s.tempo = [{ tick: 0, usPerQuarter: Math.round(6e7 / v) }]; });
67 }}
68 />
69 </label>
70 <label title="Metronome during playback and recording">
71 <input type="checkbox" checked={player.metronome} onChange={(e) => { player.metronome = e.target.checked; }} /> click
72 </label>
73 <select
74 value={app.snapBeats}
75 onChange={(e) => store.setSnapBeats(Number(e.target.value))}
76 title="Snap grid for editing and quantize"
77 >
78 {SNAP_OPTIONS.map((o) => (
79 <option key={o.label} value={o.beats}>{o.label}</option>
80 ))}
81 </select>
82 <button onClick={() => store.quantize()} title="Quantize selected notes (or the active track) to the snap grid">Quantize</button>
83 </div>
84 );
85}