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>
cdfbf5bRender whole tracks through direct-import instrument modulesJeremy Magland 54 {player.preparing && (
55 <span className="status">
56 {(() => {
57 const entries = [...player.renderProgress.entries()];
58 if (entries.length === 0) return 'rendering…';
59 const [id, p] = entries[entries.length - 1];
60 const name = song.tracks.find((t) => t.id === id)?.name ?? 'track';
61 return `rendering ${name}: ${p.stage} ${Math.round(p.frac * 100)}%`;
62 })()}
63 {' '}
64 <button title="Cancel rendering" onClick={() => player.cancelRender()}>✕</button>
65 </span>
66 )}
0ad1ec1midiboot: in-browser MIDI workstation with pluggable instrumentsJeremy Magland 67 <span className="spacer" />
68 <label title={singleTempo ? 'Tempo (bpm)' : 'This song has a tempo map; editing it as one bpm is not supported yet'}>
69 bpm{' '}
70 <input
71 type="number"
72 min={20}
73 max={300}
74 value={bpm}
75 disabled={!singleTempo}
76 onChange={(e) => {
77 const v = Math.max(20, Math.min(300, Number(e.target.value) || 120));
78 store.mutateSong((s) => { s.tempo = [{ tick: 0, usPerQuarter: Math.round(6e7 / v) }]; });
79 }}
80 />
81 </label>
82 <label title="Metronome during playback and recording">
83 <input type="checkbox" checked={player.metronome} onChange={(e) => { player.metronome = e.target.checked; }} /> click
84 </label>
85 <select
86 value={app.snapBeats}
87 onChange={(e) => store.setSnapBeats(Number(e.target.value))}
88 title="Snap grid for editing and quantize"
89 >
90 {SNAP_OPTIONS.map((o) => (
91 <option key={o.label} value={o.beats}>{o.label}</option>
92 ))}
93 </select>
94 <button onClick={() => store.quantize()} title="Quantize selected notes (or the active track) to the snap grid">Quantize</button>
96 className={app.loopEnabled && app.rangeTicks ? 'toggled' : ''}
97 disabled={!app.rangeTicks}
98 onClick={() => store.toggleLoop()}
99 title="Loop playback over the time selection (drag in the ruler to make one)"
100 >Loop</button>
101 {app.rangeTicks && (
102 <span className="rangeops">
103 <span className="pos" title="time selection (bar.beat)">
104 {(() => {
105 const A = tm.barBeatAt(app.rangeTicks[0]);
106 const B = tm.barBeatAt(app.rangeTicks[1]);
107 return `${A.bar}.${A.beat} to ${B.bar}.${B.beat}`;
108 })()}
109 </span>
110 <button onClick={() => store.rangeSelectNotes()} title="Select the notes inside the time selection">Select</button>
111 <button onClick={() => store.rangeClearNotes()} title="Delete the notes inside the time selection, keeping the time (also the delete key when no notes are selected)">Clear</button>
112 <button onClick={() => store.rangeDeleteTime()} title="Delete the selected time span on every track and close the gap; the tempo map ripples too">Cut time</button>
113 <button onClick={() => store.rangeInsertTime()} title="Insert that much empty time at the selection start, on every track">Insert</button>
114 <button onClick={() => store.rangeDuplicateTime()} title="Insert a copy of the selection immediately after itself">Dup</button>
115 </span>
116 )}
118 );
119}