midiboot: in-browser MIDI workstation with pluggable instruments
Record from any Web MIDI device or the typing keyboard, edit in a
full piano roll (move/resize/velocity/marquee/undo), bind tracks to
instruments behind a small render/noteOn interface, play via cached
offline per-track renders, and import/export standard MIDI files.
Songs autosave to an IndexedDB library. Node-side tests cover the
MIDI round trip, tempo math, editing ops, DSP determinism, and
mixing rules.
35 changed files+6385−0
.gitignoreadded+2−0View file
@@ -0,0 +1,2 @@
1+node_modules
2+dist
README.mdadded+52−0View file
@@ -0,0 +1,52 @@
1+# midiboot
2+
3+An in-browser application for recording, importing, editing, and playing MIDI. A limitation of most browser MIDI tools is that the instrument is fixed: notes go into a General MIDI soundfont or a hard-coded synth, and there is no place to put your own rendering logic. Here we describe a small workstation built around one extension point, an instrument interface that turns note events into audio, so that the built-in synths can later be joined by custom renderers (a GPU physical model in the style of [instrawave](../instrawave), a sampler, anything) without changing the editor, the transport, or the file handling around them.
4+
5+## The instrument interface
6+
7+An instrument is anything implementing the interface in [src/instruments/types.ts](src/instruments/types.ts):
8+
9+```ts
10+interface Instrument {
11+ id: string;
12+ name: string;
13+ render(req: RenderRequest): Float32Array | Promise<Float32Array>;
14+ noteOn?(ctx: BaseAudioContext, dest: AudioNode, pitch: number,
15+ velocity: number, whenSec: number): LiveVoice;
16+}
17+```
18+
19+There are two paths. The offline path, `render`, receives every note of a track with times already converted to seconds and produces one mono buffer; it may be async and arbitrarily expensive, and playback, mixing, and WAV export all go through it. The live path, `noteOn`, is optional and exists for low-latency monitoring while recording and for previewing notes from the piano roll; an instrument without it still works everywhere, with a plain beep standing in for monitoring. New instruments are added with `registerInstrument` in [src/instruments/registry.ts](src/instruments/registry.ts); tracks refer to instruments by id, so a song whose instrument is missing falls back to a builtin rather than failing.
20+
21+The four builtin instruments (Karplus-Strong pluck, FM electric piano, a filtered square lead, and a General MIDI drum kit) are constructed from pure per-note DSP functions in [src/instruments/dsp.ts](src/instruments/dsp.ts). These are deterministic, with noise drawn from a PRNG seeded by pitch, so the node test harness checks the actual audio. The `instrumentFromNoteDsp` helper derives both paths from one such function; a custom instrument is free to ignore it and implement the interface directly.
22+
23+## Recording
24+
25+Recording takes input from any connected MIDI device through the Web MIDI API, following hot-plugging via statechange, with a selector for one input or all of them at once. There is also a typing-keyboard mode (the a through ' row plays white keys, the row above the black keys, z and x shift the octave) for working without hardware. Pressing record gives a one-bar count-in of clicks at the current tempo and signature, then captures note on/off against the transport clock while the other tracks play; input is monitored live through the armed track's instrument. When the transport stops, the take is quantized only in the sense of being converted to ticks, is committed to the armed track as a single undo step, and is left selected so that Quantize (to the snap grid) is one click away.
26+
27+## Editing
28+
29+The piano roll is a single canvas: ruler, key column, note grid, and a velocity lane. Click on empty space to add a note (drag right to set its length), drag notes to move them, drag a note's right edge to resize, shift+drag for marquee selection, right-click to delete, and drag in the velocity lane to paint velocities. Snap is selectable from a bar down to 1/32, holding alt bypasses it, and ctrl+wheel zooms. The active track is edited in full color while the other tracks show as ghosts. Undo and redo (ctrl+z, ctrl+shift+z) cover every edit, with a whole drag gesture counting as one step. Tracks carry name, instrument, volume, mute, solo, and a record arm.
30+
31+## Playback and rendering
32+
33+Playback is offline-first: each track is rendered by its instrument into a buffer, cached under a key derived from everything that affects the audio (notes, tempo map, ppq, instrument), and the buffers are played through per-track gains. Volume, mute, and solo act at mix time and never cause a re-render, an arrangement borrowed from [instrawave-midi](../instrawave-midi). This is what makes expensive future instruments practical: a render costs what it costs, once, and editing anything else costs nothing. The trade-off is that pressing play after an edit waits for the affected track to re-render, which for the builtin synths is not noticeable. Export produces a 16-bit mono WAV at 44.1 kHz, or a standard MIDI file (format 1, with the tempo and time signature maps and with drum tracks on channel 10).
34+
35+## Files and storage
36+
37+Songs autosave to IndexedDB a moment after each edit, and the Library dialog lists, opens, renames, duplicates, and deletes them. Import reads standard MIDI files, formats 0 and 1, with running status and merged tempo maps; each (track, channel) pair carrying notes becomes an editable track, channel 10 defaults to the drum kit, and track names fall back to General MIDI program names. Note that import keeps only notes, tempo, and time signature: controllers, pitch bend, and aftertouch are parsed past and dropped, and SMPTE-timed files are refused rather than misread.
38+
39+## Development
40+
41+```
42+npm install
43+npm run dev # vite dev server
44+npm run build # typecheck and bundle
45+npm test # node-side tests (no browser, no audio device)
46+```
47+
48+The tests cover the MIDI writer/parser round trip, tempo map arithmetic, quantize and transpose, mixing rules, and the determinism and boundedness of the DSP.
49+
50+## Limitations and future work
51+
52+The mixdown is mono, matching the mono instrument interface; a stereo render path is a straightforward extension. There is no loop region, no controller or pitch bend data, and editing the tempo of a song that already has a tempo map is not supported (the bpm field edits only single-tempo songs rather than silently flattening the map). The live path plays each note as a pre-rendered buffer with a gain release, which is fine for monitoring but is not a substitute for a real streaming voice. Future work will add an instrawave adapter as the first external instrument, at which point render latency and cache persistence (IndexedDB rather than memory) become the interesting problems.
index.htmladded+12−0View file
@@ -0,0 +1,12 @@
1+<!doctype html>
2+<html lang="en">
3+ <head>
4+ <meta charset="UTF-8" />
5+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6+ <title>midiboot</title>
7+ </head>
8+ <body>
9+ <div id="root"></div>
10+ <script type="module" src="/src/main.tsx"></script>
11+ </body>
12+</html>
package-lock.jsonadded+3067−0View file
This diff is 3,072 lines long and is not shown.
package.jsonadded+28−0View file
@@ -0,0 +1,28 @@
1+{
2+ "name": "midiboot",
3+ "version": "0.1.0",
4+ "description": "In-browser MIDI workstation: record from any MIDI device or the typing keyboard, edit in a piano roll, bind tracks to pluggable instruments, play and render",
5+ "type": "module",
6+ "engines": {
7+ "node": ">=22.6"
8+ },
9+ "license": "Apache-2.0",
10+ "scripts": {
11+ "dev": "vite",
12+ "build": "tsc --noEmit && vite build",
13+ "test": "vite-node scripts/test-node.ts"
14+ },
15+ "dependencies": {
16+ "react": "^18.3.1",
17+ "react-dom": "^18.3.1"
18+ },
19+ "devDependencies": {
20+ "@types/node": "^26.1.1",
21+ "@types/react": "^18.3.3",
22+ "@types/react-dom": "^18.3.0",
23+ "@vitejs/plugin-react": "^4.3.1",
24+ "typescript": "^5.5.0",
25+ "vite": "^5.4.0",
26+ "vite-node": "^6.0.0"
27+ }
28+}
scripts/test-node.tsadded+253−0View file
@@ -0,0 +1,253 @@
1+// Deterministic node-side tests: MIDI round trip, tempo math, editing
2+// ops, DSP determinism, and mixing rules. Run with: npm test
3+
4+import assert from 'node:assert/strict';
5+import { TempoMap } from '../src/midi/time.ts';
6+import { parseMidi } from '../src/midi/parse.ts';
7+import { writeMidi } from '../src/midi/write.ts';
8+import { quantizeNotes, transposeNotes } from '../src/midi/ops.ts';
9+import { drumNote, epianoNote, leadNote, pluckNote } from '../src/instruments/dsp.ts';
10+import { builtinInstruments } from '../src/instruments/builtins.ts';
11+import { audibleTracks, encodeWavMono16, mixTracks, trackRenderKey } from '../src/audio/render.ts';
12+import type { Note, Song, Track } from '../src/types.ts';
13+
14+let failed = 0;
15+let passed = 0;
16+function test(name: string, fn: () => void) {
17+ try {
18+ fn();
19+ passed++;
20+ console.log(`ok - ${name}`);
21+ } catch (err) {
22+ failed++;
23+ console.error(`FAIL - ${name}`);
24+ console.error(err);
25+ }
26+}
27+
28+function close(a: number, b: number, tol = 1e-9) {
29+ assert.ok(Math.abs(a - b) <= tol, `${a} !~ ${b}`);
30+}
31+
32+function hash(buf: Float32Array): number {
33+ let h = 0x811c9dc5;
34+ for (let i = 0; i < buf.length; i++) {
35+ h ^= Math.round(buf[i] * 1e6) & 0xffff;
36+ h = Math.imul(h, 0x01000193);
37+ }
38+ return h >>> 0;
39+}
40+
41+function mkNote(tick: number, duration: number, pitch: number, velocity: number, id: string): Note {
42+ return { id, tick, duration, pitch, velocity };
43+}
44+
45+function mkTrack(id: string, instrumentId: string, notes: Note[], extra: Partial<Track> = {}): Track {
46+ return { id, name: id, instrumentId, volume: 1, mute: false, solo: false, notes, ...extra };
47+}
48+
49+// --- tempo map ---
50+
51+test('tempo map: constant 120 bpm', () => {
52+ const tm = new TempoMap(480, [{ tick: 0, usPerQuarter: 500000 }]);
53+ close(tm.tickToSec(480), 0.5);
54+ close(tm.tickToSec(1920), 2);
55+ close(tm.secToTick(1), 960);
56+ close(tm.bpmAt(0), 120);
57+});
58+
59+test('tempo map: tempo change mid-song', () => {
60+ const tm = new TempoMap(480, [
61+ { tick: 0, usPerQuarter: 500000 },
62+ { tick: 960, usPerQuarter: 250000 },
63+ ]);
64+ close(tm.tickToSec(960), 1);
65+ close(tm.tickToSec(1920), 1.5);
66+ close(tm.secToTick(1.25), 1440);
67+ close(tm.bpmAt(1000), 240);
68+});
69+
70+test('tempo map: bar.beat display in 4/4 and 3/4', () => {
71+ const tm44 = new TempoMap(480, [], [{ tick: 0, numerator: 4, denominator: 4 }]);
72+ assert.deepEqual(tm44.barBeatAt(0), { bar: 1, beat: 1 });
73+ assert.deepEqual(tm44.barBeatAt(480 * 5), { bar: 2, beat: 2 });
74+ const tm34 = new TempoMap(480, [], [{ tick: 0, numerator: 3, denominator: 4 }]);
75+ assert.deepEqual(tm34.barBeatAt(480 * 3), { bar: 2, beat: 1 });
76+});
77+
78+test('tempo map: grid lines mark bars', () => {
79+ const tm = new TempoMap(480, [], [{ tick: 0, numerator: 4, denominator: 4 }]);
80+ const lines = tm.gridLines(0, 1920);
81+ assert.equal(lines.length, 4);
82+ assert.deepEqual(lines[0], { tick: 0, isBar: true });
83+ assert.deepEqual(lines[1], { tick: 480, isBar: false });
84+});
85+
86+// --- MIDI round trip ---
87+
88+function sortedNotes(t: Track): [number, number, number, number][] {
89+ return t.notes
90+ .map((n) => [n.tick, n.duration, n.pitch, n.velocity] as [number, number, number, number])
91+ .sort((a, b) => a[0] - b[0] || a[2] - b[2]);
92+}
93+
94+test('MIDI write/parse round trip preserves notes, tempo, and drums', () => {
95+ const song: Song = {
96+ ppq: 480,
97+ tempo: [
98+ { tick: 0, usPerQuarter: 500000 },
99+ { tick: 1920, usPerQuarter: 400000 },
100+ ],
101+ timeSig: [{ tick: 0, numerator: 3, denominator: 4 }],
102+ tracks: [
103+ mkTrack('melody', 'pluck', [
104+ mkNote(0, 480, 60, 100, 'a'),
105+ mkNote(480, 240, 64, 90, 'b'),
106+ mkNote(100000, 960, 72, 127, 'c'), // multi-byte delta times
107+ mkNote(480, 240, 67, 1, 'd'), // chord + min velocity
108+ ]),
109+ mkTrack('kit', 'drumkit', [
110+ mkNote(0, 120, 36, 110, 'e'),
111+ mkNote(480, 120, 38, 80, 'f'),
112+ ]),
113+ ],
114+ };
115+ const bytes = writeMidi(song);
116+ const back = parseMidi(bytes);
117+ assert.equal(back.ppq, 480);
118+ assert.equal(back.tracks.length, 2);
119+ assert.deepEqual(sortedNotes(back.tracks[0]), sortedNotes(song.tracks[0]));
120+ assert.deepEqual(sortedNotes(back.tracks[1]), sortedNotes(song.tracks[1]));
121+ assert.equal(back.tracks[0].name, 'melody');
122+ assert.equal(back.tracks[1].instrumentId, 'drumkit'); // came back on channel 10
123+ assert.equal(back.tracks[0].instrumentId, 'pluck');
124+ assert.deepEqual(back.tempo, song.tempo);
125+ assert.equal(back.timeSig[0].numerator, 3);
126+ assert.equal(back.timeSig[0].denominator, 4);
127+});
128+
129+test('parse splits a multi-channel track by channel', () => {
130+ const song: Song = {
131+ ppq: 480,
132+ tempo: [{ tick: 0, usPerQuarter: 500000 }],
133+ timeSig: [{ tick: 0, numerator: 4, denominator: 4 }],
134+ tracks: [
135+ mkTrack('a', 'pluck', [mkNote(0, 480, 60, 100, 'a')]),
136+ mkTrack('b', 'drumkit', [mkNote(0, 120, 42, 100, 'b')]),
137+ ],
138+ };
139+ // write produces separate MTrks; merging them into one channel-mixed
140+ // stream is what a format 0 file would look like, so parse the format 1
141+ // bytes and just confirm channels landed apart.
142+ const back = parseMidi(writeMidi(song));
143+ assert.equal(back.tracks.length, 2);
144+ assert.equal(back.tracks[1].instrumentId, 'drumkit');
145+});
146+
147+// --- editing ops ---
148+
149+test('quantize rounds note starts to the grid', () => {
150+ const notes = [mkNote(130, 100, 60, 100, 'a'), mkNote(59, 100, 61, 100, 'b')];
151+ const q = quantizeNotes(notes, 120);
152+ assert.equal(q[0].tick, 120);
153+ assert.equal(q[1].tick, 0); // 59 rounds down to the grid origin
154+ const onlyA = quantizeNotes(notes, 120, new Set(['a']));
155+ assert.equal(onlyA[0].tick, 120);
156+ assert.equal(onlyA[1].tick, 59);
157+});
158+
159+test('transpose clamps to the MIDI range', () => {
160+ const notes = [mkNote(0, 100, 126, 100, 'a')];
161+ assert.equal(transposeNotes(notes, 12)[0].pitch, 127);
162+ assert.equal(transposeNotes(notes, -300)[0].pitch, 0);
163+});
164+
165+// --- DSP ---
166+
167+test('DSP is deterministic and finite', () => {
168+ for (const [name, fn] of [
169+ ['pluck', pluckNote],
170+ ['epiano', epianoNote],
171+ ['lead', leadNote],
172+ ['drum', drumNote],
173+ ] as const) {
174+ const a = fn(44100, name === 'drum' ? 38 : 60, 0.8, 0.4);
175+ const b = fn(44100, name === 'drum' ? 38 : 60, 0.8, 0.4);
176+ assert.equal(hash(a), hash(b), `${name} not deterministic`);
177+ assert.ok(a.length > 1000, `${name} too short`);
178+ let peak = 0;
179+ for (let i = 0; i < a.length; i++) {
180+ assert.ok(Number.isFinite(a[i]), `${name} produced non-finite sample`);
181+ peak = Math.max(peak, Math.abs(a[i]));
182+ }
183+ assert.ok(peak > 0.01, `${name} is silent`);
184+ assert.ok(peak <= 1.5, `${name} peak too hot: ${peak}`);
185+ }
186+});
187+
188+test('builtin instrument render places notes at the right time', () => {
189+ const pluck = builtinInstruments.find((i) => i.id === 'pluck')!;
190+ const sr = 44100;
191+ const out = pluck.render({
192+ notes: [{ timeSec: 1, durationSec: 0.25, pitch: 69, velocity: 0.8 }],
193+ sampleRate: sr,
194+ lengthSamples: sr * 3,
195+ }) as Float32Array;
196+ assert.equal(out.length, sr * 3);
197+ let before = 0;
198+ for (let i = 0; i < sr - 10; i++) before = Math.max(before, Math.abs(out[i]));
199+ let after = 0;
200+ for (let i = sr; i < sr * 2; i++) after = Math.max(after, Math.abs(out[i]));
201+ assert.equal(before, 0);
202+ assert.ok(after > 0.01);
203+});
204+
205+// --- mixing ---
206+
207+test('mute and solo select audible tracks', () => {
208+ const a = mkTrack('a', 'pluck', []);
209+ const b = mkTrack('b', 'pluck', [], { mute: true });
210+ const c = mkTrack('c', 'pluck', []);
211+ assert.deepEqual([...audibleTracks([a, b, c])].sort(), ['a', 'c']);
212+ const cSolo = { ...c, solo: true };
213+ assert.deepEqual([...audibleTracks([a, b, cSolo])], ['c']);
214+});
215+
216+test('mixTracks applies volume and excludes muted tracks', () => {
217+ const a = mkTrack('a', 'pluck', [], { volume: 0.5 });
218+ const b = mkTrack('b', 'pluck', [], { mute: true });
219+ const buffers = new Map<string, Float32Array>([
220+ ['a', new Float32Array([0.4, 0.4])],
221+ ['b', new Float32Array([0.9, 0.9])],
222+ ]);
223+ const mix = mixTracks([a, b], buffers);
224+ close(mix[0], 0.2, 1e-6);
225+});
226+
227+test('trackRenderKey changes when the notes change', () => {
228+ const song: Song = {
229+ ppq: 480,
230+ tempo: [{ tick: 0, usPerQuarter: 500000 }],
231+ timeSig: [{ tick: 0, numerator: 4, denominator: 4 }],
232+ tracks: [],
233+ };
234+ const t1 = mkTrack('a', 'pluck', [mkNote(0, 480, 60, 100, 'a')]);
235+ const t2 = mkTrack('a', 'pluck', [mkNote(0, 480, 61, 100, 'a')]);
236+ const t3 = mkTrack('a', 'epiano', [mkNote(0, 480, 60, 100, 'a')]);
237+ assert.equal(trackRenderKey(song, t1), trackRenderKey(song, mkTrack('a', 'pluck', [mkNote(0, 480, 60, 100, 'zzz')])));
238+ assert.notEqual(trackRenderKey(song, t1), trackRenderKey(song, t2));
239+ assert.notEqual(trackRenderKey(song, t1), trackRenderKey(song, t3));
240+});
241+
242+test('WAV encoding writes a well-formed header', () => {
243+ const wav = encodeWavMono16(new Float32Array([0, 0.5, -0.5, 2]), 44100);
244+ assert.equal(wav.length, 44 + 8);
245+ assert.equal(String.fromCharCode(...wav.subarray(0, 4)), 'RIFF');
246+ assert.equal(String.fromCharCode(...wav.subarray(8, 12)), 'WAVE');
247+ const dv = new DataView(wav.buffer);
248+ assert.equal(dv.getUint32(24, true), 44100);
249+ assert.equal(dv.getInt16(44 + 6, true), 32767); // clipped to full scale
250+});
251+
252+console.log(`\n${passed} passed, ${failed} failed`);
253+process.exit(failed > 0 ? 1 : 0);
src/audio/player.tsadded+224−0View file
@@ -0,0 +1,224 @@
1+import type { Song } from '../types.ts';
2+import type { LiveVoice } from '../instruments/types.ts';
3+import { getInstrument } from '../instruments/registry.ts';
4+import { TempoMap } from '../midi/time.ts';
5+import { audibleTracks, encodeWavMono16, mixTracks, renderTrackBuffer, trackRenderKey } from './render.ts';
6+
7+// The transport. Playback is offline-rendered per track (cached, keyed by
8+// everything that affects the audio) and played as buffer sources, so an
9+// expensive instrument costs one render, not a realtime budget. The live
10+// path (previewNoteOn) is for monitoring while recording and for
11+// previewing notes; it goes through the instrument's noteOn.
12+
13+export type TransportState = 'stopped' | 'playing' | 'recording';
14+
15+const EXPORT_SAMPLE_RATE = 44100;
16+
17+function scheduleClick(ctx: BaseAudioContext, dest: AudioNode, when: number, accent: boolean) {
18+ const osc = ctx.createOscillator();
19+ osc.frequency.value = accent ? 1760 : 1175;
20+ const gain = ctx.createGain();
21+ gain.gain.setValueAtTime(accent ? 0.5 : 0.3, when);
22+ gain.gain.exponentialRampToValueAtTime(0.001, when + 0.06);
23+ osc.connect(gain);
24+ gain.connect(dest);
25+ osc.start(when);
26+ osc.stop(when + 0.08);
27+}
28+
29+class Player {
30+ private ctx: AudioContext | null = null;
31+ private master: GainNode | null = null;
32+ private sources: AudioBufferSourceNode[] = [];
33+ private cache = new Map<string, { key: string; buffer: Float32Array }>();
34+ private metroTimer: ReturnType<typeof setInterval> | null = null;
35+ private raf = 0;
36+ private lastEmit = 0;
37+ private startCtxTime = 0;
38+ private startPosSec = 0;
39+ private endSec = 0;
40+ private listeners = new Set<() => void>();
41+ version = 0;
42+ state: TransportState = 'stopped';
43+ posSec = 0;
44+ metronome = true;
45+ preparing = false;
46+
47+ subscribe = (cb: () => void): (() => void) => {
48+ this.listeners.add(cb);
49+ return () => this.listeners.delete(cb);
50+ };
51+
52+ private emit() {
53+ this.version++;
54+ for (const cb of this.listeners) cb();
55+ }
56+
57+ ensureCtx(): AudioContext {
58+ if (!this.ctx) {
59+ this.ctx = new AudioContext();
60+ this.master = this.ctx.createGain();
61+ this.master.gain.value = 0.9;
62+ this.master.connect(this.ctx.destination);
63+ }
64+ if (this.ctx.state === 'suspended') void this.ctx.resume();
65+ return this.ctx;
66+ }
67+
68+ getPositionSec(): number {
69+ if (this.state === 'stopped' || !this.ctx) return this.posSec;
70+ return this.startPosSec + (this.ctx.currentTime - this.startCtxTime);
71+ }
72+
73+ private async prepareTracks(song: Song, sampleRate: number): Promise<void> {
74+ const ids = new Set(song.tracks.map((t) => t.id));
75+ for (const id of [...this.cache.keys()]) if (!ids.has(id)) this.cache.delete(id);
76+ for (const track of song.tracks) {
77+ const key = trackRenderKey(song, track) + '@' + sampleRate;
78+ const cached = this.cache.get(track.id);
79+ if (cached && cached.key === key) continue;
80+ const buffer = await renderTrackBuffer(song, track, getInstrument(track.instrumentId), sampleRate);
81+ this.cache.set(track.id, { key, buffer });
82+ }
83+ }
84+
85+ async play(song: Song, opts: { record?: boolean; countInBars?: number } = {}): Promise<void> {
86+ if (this.state !== 'stopped') this.stop();
87+ const ctx = this.ensureCtx();
88+ await ctx.resume();
89+ this.preparing = true;
90+ this.emit();
91+ try {
92+ await this.prepareTracks(song, ctx.sampleRate);
93+ } finally {
94+ this.preparing = false;
95+ }
96+ const tm = new TempoMap(song.ppq, song.tempo, song.timeSig);
97+
98+ // Count-in: one or more bars of clicks at the signature and tempo in
99+ // force at the start position.
100+ let countInSec = 0;
101+ const startTick = tm.secToTick(this.posSec);
102+ let sig = tm.timeSig[0];
103+ for (const s of tm.timeSig) if (s.tick <= startTick) sig = s;
104+ const beatSec = (60 / tm.bpmAt(startTick)) * (4 / sig.denominator);
105+ const countBeats = (opts.countInBars ?? 0) * sig.numerator;
106+ countInSec = countBeats * beatSec;
107+
108+ const t0 = ctx.currentTime + 0.12 + countInSec;
109+ for (let k = 0; k < countBeats; k++) {
110+ scheduleClick(ctx, this.master!, t0 - countInSec + k * beatSec, k % sig.numerator === 0);
111+ }
112+
113+ const audible = audibleTracks(song.tracks);
114+ this.endSec = this.posSec;
115+ this.sources = [];
116+ for (const track of song.tracks) {
117+ if (!audible.has(track.id)) continue;
118+ const entry = this.cache.get(track.id);
119+ if (!entry) continue;
120+ const durSec = entry.buffer.length / ctx.sampleRate;
121+ this.endSec = Math.max(this.endSec, durSec);
122+ if (this.posSec >= durSec) continue;
123+ const buf = ctx.createBuffer(1, entry.buffer.length, ctx.sampleRate);
124+ buf.copyToChannel(entry.buffer as Float32Array<ArrayBuffer>, 0);
125+ const src = ctx.createBufferSource();
126+ src.buffer = buf;
127+ const gain = ctx.createGain();
128+ gain.gain.value = track.volume;
129+ src.connect(gain);
130+ gain.connect(this.master!);
131+ src.start(t0, this.posSec);
132+ this.sources.push(src);
133+ }
134+
135+ this.startCtxTime = t0;
136+ this.startPosSec = this.posSec;
137+ this.state = opts.record ? 'recording' : 'playing';
138+ this.emit();
139+
140+ // Metronome: schedule beat clicks a little ahead of time.
141+ if (this.metronome || opts.record) {
142+ let lastTick = Math.floor(tm.secToTick(this.posSec)) - 1;
143+ this.metroTimer = setInterval(() => {
144+ if (this.state === 'stopped' || !this.ctx) return;
145+ const now = this.getPositionSec();
146+ const horizon = now + 0.4;
147+ if (horizon < 0) return;
148+ const t0tick = Math.max(lastTick + 1, Math.ceil(tm.secToTick(Math.max(0, now))));
149+ const t1tick = Math.ceil(tm.secToTick(Math.max(0, horizon))) + 1;
150+ for (const line of tm.gridLines(t0tick, t1tick)) {
151+ if (line.tick <= lastTick) continue;
152+ const when = this.startCtxTime + (tm.tickToSec(line.tick) - this.startPosSec);
153+ if (when < this.ctx.currentTime) continue;
154+ scheduleClick(this.ctx, this.master!, when, line.isBar);
155+ lastTick = line.tick;
156+ }
157+ }, 60);
158+ }
159+
160+ const tick = () => {
161+ if (this.state === 'stopped') return;
162+ const now = performance.now();
163+ if (now - this.lastEmit > 100) {
164+ this.lastEmit = now;
165+ this.emit();
166+ }
167+ if (this.state === 'playing' && this.getPositionSec() > this.endSec + 0.3) {
168+ this.stop();
169+ return;
170+ }
171+ this.raf = requestAnimationFrame(tick);
172+ };
173+ this.raf = requestAnimationFrame(tick);
174+ }
175+
176+ stop(): void {
177+ if (this.state === 'stopped') return;
178+ this.posSec = Math.max(0, this.getPositionSec());
179+ for (const src of this.sources) {
180+ try { src.stop(); } catch { /* already stopped */ }
181+ }
182+ this.sources = [];
183+ if (this.metroTimer !== null) { clearInterval(this.metroTimer); this.metroTimer = null; }
184+ cancelAnimationFrame(this.raf);
185+ this.state = 'stopped';
186+ this.emit();
187+ }
188+
189+ seek(sec: number): void {
190+ this.posSec = Math.max(0, sec);
191+ this.emit();
192+ }
193+
194+ previewNoteOn(instrumentId: string, pitch: number, velocity127: number): LiveVoice {
195+ const ctx = this.ensureCtx();
196+ const ins = getInstrument(instrumentId);
197+ if (ins.noteOn) return ins.noteOn(ctx, this.master!, pitch, Math.max(0, Math.min(1, velocity127 / 127)), ctx.currentTime);
198+ // Fallback for instruments with no live path: a plain beep.
199+ const osc = ctx.createOscillator();
200+ osc.frequency.value = 440 * Math.pow(2, (pitch - 69) / 12);
201+ const gain = ctx.createGain();
202+ gain.gain.value = 0.2 * (velocity127 / 127);
203+ osc.connect(gain);
204+ gain.connect(this.master!);
205+ osc.start();
206+ const off = () => {
207+ const t = ctx.currentTime;
208+ gain.gain.setTargetAtTime(0, t, 0.03);
209+ osc.stop(t + 0.3);
210+ };
211+ return { noteOff: off, cut: off };
212+ }
213+
214+ async exportWav(song: Song): Promise<Uint8Array> {
215+ const buffers = new Map<string, Float32Array>();
216+ for (const track of song.tracks) {
217+ buffers.set(track.id, await renderTrackBuffer(song, track, getInstrument(track.instrumentId), EXPORT_SAMPLE_RATE));
218+ }
219+ const mix = mixTracks(song.tracks, buffers);
220+ return encodeWavMono16(mix, EXPORT_SAMPLE_RATE);
221+ }
222+}
223+
224+export const player = new Player();
src/audio/render.tsadded+108−0View file
@@ -0,0 +1,108 @@
1+import type { Song, Track } from '../types.ts';
2+import type { Instrument, NoteSpec } from '../instruments/types.ts';
3+import { TempoMap } from '../midi/time.ts';
4+
5+// Offline rendering: convert a track's notes to seconds under the tempo
6+// map, hand them to the instrument's render, and level the result. Mixing
7+// applies volume, mute, and solo, so those controls never cause a
8+// re-render.
9+
10+export const RENDER_TAIL_SEC = 2;
11+
12+export function noteSpecsForTrack(song: Song, track: Track): { notes: NoteSpec[]; lengthSec: number } {
13+ const tm = new TempoMap(song.ppq, song.tempo, song.timeSig);
14+ let lengthSec = 0;
15+ const notes: NoteSpec[] = track.notes.map((n) => {
16+ const timeSec = tm.tickToSec(n.tick);
17+ const endSec = tm.tickToSec(n.tick + n.duration);
18+ lengthSec = Math.max(lengthSec, endSec);
19+ return { timeSec, durationSec: Math.max(0.01, endSec - timeSec), pitch: n.pitch, velocity: Math.max(0, Math.min(1, n.velocity / 127)) };
20+ });
21+ return { notes, lengthSec: lengthSec + RENDER_TAIL_SEC };
22+}
23+
24+export async function renderTrackBuffer(song: Song, track: Track, instrument: Instrument, sampleRate: number): Promise<Float32Array> {
25+ const { notes, lengthSec } = noteSpecsForTrack(song, track);
26+ const lengthSamples = Math.max(1, Math.ceil(lengthSec * sampleRate));
27+ const buf = await instrument.render({ notes, sampleRate, lengthSamples });
28+ // Level each track to a common peak so mixing is predictable.
29+ let peak = 0;
30+ for (let i = 0; i < buf.length; i++) peak = Math.max(peak, Math.abs(buf[i]));
31+ if (peak > 0) {
32+ const s = 0.5 / peak;
33+ const out = new Float32Array(buf.length);
34+ for (let i = 0; i < buf.length; i++) out[i] = buf[i] * s;
35+ return out;
36+ }
37+ return buf;
38+}
39+
40+export function audibleTracks(tracks: Track[]): Set<string> {
41+ const solo = tracks.filter((t) => t.solo);
42+ const audible = new Set<string>();
43+ for (const t of tracks) {
44+ if (solo.length > 0 ? t.solo : !t.mute) audible.add(t.id);
45+ }
46+ return audible;
47+}
48+
49+export function mixTracks(tracks: Track[], buffers: Map<string, Float32Array>): Float32Array {
50+ const audible = audibleTracks(tracks);
51+ let length = 1;
52+ for (const t of tracks) {
53+ const b = buffers.get(t.id);
54+ if (b && audible.has(t.id)) length = Math.max(length, b.length);
55+ }
56+ const out = new Float32Array(length);
57+ for (const t of tracks) {
58+ if (!audible.has(t.id)) continue;
59+ const b = buffers.get(t.id);
60+ if (!b) continue;
61+ for (let i = 0; i < b.length; i++) out[i] += b[i] * t.volume;
62+ }
63+ let peak = 0;
64+ for (let i = 0; i < out.length; i++) peak = Math.max(peak, Math.abs(out[i]));
65+ if (peak > 0.98) {
66+ const s = 0.98 / peak;
67+ for (let i = 0; i < out.length; i++) out[i] *= s;
68+ }
69+ return out;
70+}
71+
72+export function trackRenderKey(song: Song, track: Track): string {
73+ // FNV-1a over everything that affects the rendered audio.
74+ const parts = [String(song.ppq), track.instrumentId, JSON.stringify(song.tempo)];
75+ for (const n of track.notes) parts.push(`${n.tick},${n.duration},${n.pitch},${n.velocity}`);
76+ let h = 0x811c9dc5;
77+ const s = parts.join(';');
78+ for (let i = 0; i < s.length; i++) {
79+ h ^= s.charCodeAt(i);
80+ h = Math.imul(h, 0x01000193);
81+ }
82+ return (h >>> 0).toString(36) + ':' + s.length.toString(36);
83+}
84+
85+export function encodeWavMono16(samples: Float32Array, sampleRate: number): Uint8Array {
86+ const n = samples.length;
87+ const out = new Uint8Array(44 + n * 2);
88+ const dv = new DataView(out.buffer);
89+ const str = (off: number, s: string) => { for (let i = 0; i < s.length; i++) out[off + i] = s.charCodeAt(i); };
90+ str(0, 'RIFF');
91+ dv.setUint32(4, 36 + n * 2, true);
92+ str(8, 'WAVE');
93+ str(12, 'fmt ');
94+ dv.setUint32(16, 16, true);
95+ dv.setUint16(20, 1, true);
96+ dv.setUint16(22, 1, true);
97+ dv.setUint32(24, sampleRate, true);
98+ dv.setUint32(28, sampleRate * 2, true);
99+ dv.setUint16(32, 2, true);
100+ dv.setUint16(34, 16, true);
101+ str(36, 'data');
102+ dv.setUint32(40, n * 2, true);
103+ for (let i = 0; i < n; i++) {
104+ const v = Math.max(-1, Math.min(1, samples[i]));
105+ dv.setInt16(44 + i * 2, Math.round(v * 32767), true);
106+ }
107+ return out;
108+}
src/components/App.tsxadded+142−0View file
@@ -0,0 +1,142 @@
1+import { useEffect, useRef } from 'react';
2+import { store, useApp } from '../state/store.ts';
3+import { player } from '../audio/player.ts';
4+import { midiManager, type NoteInputEvent } from '../input/webmidi.ts';
5+import { keyboardPiano } from '../input/keyboardPiano.ts';
6+import { writeMidi } from '../midi/write.ts';
7+import type { LiveVoice } from '../instruments/types.ts';
8+import { DeviceBar } from './DeviceBar.tsx';
9+import { Transport } from './Transport.tsx';
10+import { TrackList } from './TrackList.tsx';
11+import { PianoRoll } from './PianoRoll.tsx';
12+import { Library } from './Library.tsx';
13+
14+function 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);
22+}
23+
24+function inTextField(): boolean {
25+ const el = document.activeElement;
26+ return !!el && (el.tagName === 'INPUT' || el.tagName === 'TEXTAREA' || (el as HTMLElement).isContentEditable);
27+}
28+
29+export function App() {
30+ const app = useApp();
31+ const fileRef = useRef<HTMLInputElement | null>(null);
32+ const voices = useRef(new Map<string, LiveVoice>());
33+
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+ }, []);
63+
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+ }, []);
89+
90+ if (!app.ready) return <div className="loading">loading…</div>;
91+ const song = app.rec?.song ?? null;
92+
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+ );
142+}
src/components/DeviceBar.tsxadded+53−0View file
@@ -0,0 +1,53 @@
1+import { useSyncExternalStore } from 'react';
2+import { midiManager } from '../input/webmidi.ts';
3+import { keyboardPiano } from '../input/keyboardPiano.ts';
4+
5+function useMidiState() {
6+ return useSyncExternalStore(
7+ (cb) => midiManager.subscribeState(cb),
8+ () => midiManager.getState(),
9+ );
10+}
11+
12+function useKbdState(): number {
13+ // encodes enabled+octave so the snapshot is a stable primitive
14+ return useSyncExternalStore(
15+ (cb) => keyboardPiano.subscribeState(cb),
16+ () => (keyboardPiano.enabled ? keyboardPiano.octave : -1),
17+ );
18+}
19+
20+export function DeviceBar() {
21+ const midi = useMidiState();
22+ const kbd = useKbdState();
23+ return (
24+ <div className="devicebar">
25+ {midi.supported ? (
26+ midi.requested && !midi.error ? (
27+ <>
28+ <span className={`dot ${midi.inputs.length > 0 ? 'ok' : ''}`} title={midi.inputs.length > 0 ? 'MIDI inputs connected' : 'no MIDI inputs'} />
29+ <select value={midi.selectedId} onChange={(e) => midiManager.select(e.target.value)} title="MIDI input device">
30+ <option value="all">All MIDI inputs ({midi.inputs.length})</option>
31+ {midi.inputs.map((i) => (
32+ <option key={i.id} value={i.id}>{i.name}</option>
33+ ))}
34+ </select>
35+ </>
36+ ) : midi.error ? (
37+ <span className="err" title={midi.error}>MIDI unavailable</span>
38+ ) : (
39+ <button onClick={() => void midiManager.request()}>Enable MIDI</button>
40+ )
41+ ) : (
42+ <span className="err">No Web MIDI in this browser</span>
43+ )}
44+ <button
45+ className={kbd >= 0 ? 'toggled' : ''}
46+ onClick={() => keyboardPiano.setEnabled(kbd < 0)}
47+ title="Play with the typing keyboard (a..' row; z/x shift octave)"
48+ >
49+ {kbd >= 0 ? `Keys: C${kbd}` : 'Keys: off'}
50+ </button>
51+ </div>
52+ );
53+}
src/components/Library.tsxadded+39−0View file
@@ -0,0 +1,39 @@
1+import { store, useApp } from '../state/store.ts';
2+
3+export function Library() {
4+ const app = useApp();
5+ if (!app.libraryOpen) return null;
6+ return (
7+ <div className="modal-backdrop" onClick={() => store.setLibraryOpen(false)}>
8+ <div className="modal" onClick={(e) => e.stopPropagation()}>
9+ <div className="modal-head">
10+ <h2>Songs</h2>
11+ <button onClick={() => void store.newSong()}>New song</button>
12+ <span className="spacer" />
13+ <button onClick={() => store.setLibraryOpen(false)}>Close</button>
14+ </div>
15+ <div className="songlist">
16+ {app.songs.length === 0 && <p className="dim">No songs yet.</p>}
17+ {app.songs.map((s) => (
18+ <div key={s.id} className={`songrow ${s.id === app.rec?.id ? 'active' : ''}`}>
19+ <button className="songopen" onClick={() => void store.openSong(s.id)} title="Open">
20+ <span className="songname">{s.name}</span>
21+ <span className="dim">
22+ {s.trackCount} track{s.trackCount === 1 ? '' : 's'}, {s.noteCount} notes, {new Date(s.updatedAt).toLocaleString()}
23+ </span>
24+ </button>
25+ <button className="mini" title="Duplicate" onClick={() => void store.duplicateSong(s.id)}>⧉</button>
26+ <button
27+ className="mini danger"
28+ title="Delete"
29+ onClick={() => {
30+ if (window.confirm(`Delete "${s.name}"? This cannot be undone.`)) void store.deleteSongById(s.id);
31+ }}
32+ >✕</button>
33+ </div>
34+ ))}
35+ </div>
36+ </div>
37+ </div>
38+ );
39+}
src/components/PianoRoll.tsxadded+520−0View file
@@ -0,0 +1,520 @@
1+import { useEffect, useRef } from 'react';
2+import { store } from '../state/store.ts';
3+import { player } from '../audio/player.ts';
4+import { TempoMap } from '../midi/time.ts';
5+import { newId, songEndTick, type Note, type Song } from '../types.ts';
6+import { noteName } from '../midi/gm.ts';
7+import { trackColor } from './colors.ts';
8+import { isBlackKey, KEY_H, KEYS_W, PITCH_MAX, PITCH_MIN, RULER_H, snapValue, VEL_H } from '../pianoroll/geometry.ts';
9+
10+// The piano roll: a single canvas holding the ruler, key column, note
11+// grid, and velocity lane. It redraws every animation frame from current
12+// state, which keeps the playhead and edits in sync without React
13+// re-renders. Gestures:
14+// click empty space add a note (drag right to set length)
15+// drag a note move it (and the rest of the selection)
16+// drag a note's right edge resize
17+// shift+drag empty space marquee select
18+// right-click a note delete it
19+// drag in the ruler move the playhead
20+// drag in the velocity lane paint velocities
21+// wheel / shift+wheel scroll; ctrl+wheel zooms
22+// alt while dragging bypass snap
23+
24+interface OrigNote { tick: number; duration: number; pitch: number }
25+
26+type Drag =
27+ | { mode: 'scrub' }
28+ | { mode: 'keys'; pitch: number; voice: { noteOff(): void; cut(): void } | null }
29+ | { mode: 'velocity' }
30+ | { mode: 'marquee'; x0: number; y0: number; x1: number; y1: number }
31+ | { mode: 'create'; id: string; startTick: number }
32+ | { mode: 'move'; startX: number; startY: number; anchorId: string; origs: Map<string, OrigNote>; lastBlipPitch: number }
33+ | { mode: 'resize'; startX: number; anchorId: string; origs: Map<string, OrigNote> };
34+
35+const MIN_DUR_TICKS = 24;
36+
37+export function PianoRoll() {
38+ const canvasRef = useRef<HTMLCanvasElement | null>(null);
39+ const containerRef = useRef<HTMLDivElement | null>(null);
40+ const view = useRef({ pxPerBeat: 96, scrollX: 0, scrollY: (PITCH_MAX - 84) * KEY_H });
41+ const drag = useRef<Drag | null>(null);
42+ const lastCreateDur = useRef(0);
43+ const tmCache = useRef<{ song: Song; tm: TempoMap } | null>(null);
44+
45+ useEffect(() => {
46+ const canvas = canvasRef.current!;
47+ const container = containerRef.current!;
48+ let raf = 0;
49+ let width = 0;
50+ let height = 0;
51+
52+ const getTm = (song: Song): TempoMap => {
53+ if (!tmCache.current || tmCache.current.song !== song) {
54+ tmCache.current = { song, tm: new TempoMap(song.ppq, song.tempo, song.timeSig) };
55+ }
56+ return tmCache.current.tm;
57+ };
58+
59+ const resize = () => {
60+ const rect = container.getBoundingClientRect();
61+ width = Math.max(50, rect.width);
62+ height = Math.max(50, rect.height);
63+ const dpr = window.devicePixelRatio || 1;
64+ canvas.width = Math.round(width * dpr);
65+ canvas.height = Math.round(height * dpr);
66+ canvas.style.width = `${width}px`;
67+ canvas.style.height = `${height}px`;
68+ };
69+ const ro = new ResizeObserver(resize);
70+ ro.observe(container);
71+ resize();
72+
73+ const pxPerTick = (song: Song) => view.current.pxPerBeat / song.ppq;
74+ const tickToX = (song: Song, tick: number) => KEYS_W + tick * pxPerTick(song) - view.current.scrollX;
75+ const xToTick = (song: Song, x: number) => (x - KEYS_W + view.current.scrollX) / pxPerTick(song);
76+ const pitchToY = (p: number) => RULER_H + (PITCH_MAX - p) * KEY_H - view.current.scrollY;
77+ const yToPitch = (y: number) => PITCH_MAX - Math.floor((y + view.current.scrollY - RULER_H) / KEY_H);
78+
79+ const draw = () => {
80+ raf = requestAnimationFrame(draw);
81+ const ctx = canvas.getContext('2d');
82+ if (!ctx) return;
83+ const dpr = window.devicePixelRatio || 1;
84+ ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
85+ ctx.fillStyle = '#16181d';
86+ ctx.fillRect(0, 0, width, height);
87+ const st = store.state;
88+ const song = st.rec?.song;
89+ if (!song) return;
90+ const tm = getTm(song);
91+ const rollBottom = height - VEL_H;
92+ const selection = new Set(st.selection);
93+ const activeIdx = Math.max(0, song.tracks.findIndex((t) => t.id === st.activeTrackId));
94+ const activeTrack = song.tracks[activeIdx] as typeof song.tracks[number] | undefined;
95+
96+ // playhead position and auto-scroll
97+ const posSec = Math.max(0, player.getPositionSec());
98+ const playTick = tm.secToTick(posSec);
99+ if (player.state !== 'stopped') {
100+ const px = tickToX(song, playTick);
101+ if (px > width - 40 || px < KEYS_W) {
102+ view.current.scrollX = Math.max(0, playTick * pxPerTick(song) - 80);
103+ }
104+ }
105+
106+ // pitch rows
107+ for (let p = PITCH_MIN; p <= PITCH_MAX; p++) {
108+ const y = pitchToY(p);
109+ if (y + KEY_H < RULER_H || y > rollBottom) continue;
110+ ctx.fillStyle = isBlackKey(p) ? '#1a1d23' : '#20242c';
111+ ctx.fillRect(KEYS_W, y, width - KEYS_W, KEY_H - 0.5);
112+ }
113+
114+ // vertical grid
115+ const tick0 = Math.max(0, Math.floor(xToTick(song, KEYS_W)));
116+ const tick1 = Math.ceil(xToTick(song, width));
117+ const snapTicks = store.snapTicks();
118+ if (snapTicks > 0 && snapTicks * pxPerTick(song) > 7) {
119+ ctx.strokeStyle = 'rgba(255,255,255,0.05)';
120+ ctx.beginPath();
121+ for (let t = Math.floor(tick0 / snapTicks) * snapTicks; t <= tick1; t += snapTicks) {
122+ const x = tickToX(song, t);
123+ ctx.moveTo(x, RULER_H);
124+ ctx.lineTo(x, rollBottom);
125+ }
126+ ctx.stroke();
127+ }
128+ const lines = tm.gridLines(tick0, tick1 + 1);
129+ for (const line of lines) {
130+ const x = tickToX(song, line.tick);
131+ ctx.strokeStyle = line.isBar ? 'rgba(255,255,255,0.22)' : 'rgba(255,255,255,0.09)';
132+ ctx.beginPath();
133+ ctx.moveTo(x, RULER_H);
134+ ctx.lineTo(x, height);
135+ ctx.stroke();
136+ }
137+
138+ // notes: ghost tracks first, active track on top
139+ const drawTrackNotes = (ti: number, ghost: boolean) => {
140+ const t = song.tracks[ti];
141+ const color = trackColor(ti);
142+ for (const n of t.notes) {
143+ const x = tickToX(song, n.tick);
144+ const w = Math.max(3, n.duration * pxPerTick(song) - 1);
145+ if (x + w < KEYS_W || x > width) continue;
146+ const y = pitchToY(n.pitch);
147+ if (y + KEY_H < RULER_H || y > rollBottom) continue;
148+ ctx.globalAlpha = ghost ? 0.22 : 0.45 + 0.55 * (n.velocity / 127);
149+ ctx.fillStyle = color;
150+ ctx.fillRect(x, y + 1, w, KEY_H - 2);
151+ if (!ghost && selection.has(n.id)) {
152+ ctx.globalAlpha = 1;
153+ ctx.strokeStyle = '#fff';
154+ ctx.lineWidth = 1.5;
155+ ctx.strokeRect(x + 0.5, y + 1.5, w - 1, KEY_H - 3);
156+ ctx.lineWidth = 1;
157+ }
158+ }
159+ ctx.globalAlpha = 1;
160+ };
161+ song.tracks.forEach((_, i) => { if (i !== activeIdx) drawTrackNotes(i, true); });
162+ if (activeTrack) drawTrackNotes(activeIdx, false);
163+
164+ // marquee
165+ const d = drag.current;
166+ if (d && d.mode === 'marquee') {
167+ ctx.strokeStyle = 'rgba(120,180,255,0.9)';
168+ ctx.fillStyle = 'rgba(120,180,255,0.12)';
169+ const rx = Math.min(d.x0, d.x1);
170+ const ry = Math.min(d.y0, d.y1);
171+ ctx.fillRect(rx, ry, Math.abs(d.x1 - d.x0), Math.abs(d.y1 - d.y0));
172+ ctx.strokeRect(rx, ry, Math.abs(d.x1 - d.x0), Math.abs(d.y1 - d.y0));
173+ }
174+
175+ // velocity lane
176+ ctx.fillStyle = '#101216';
177+ ctx.fillRect(0, rollBottom, width, VEL_H);
178+ ctx.strokeStyle = 'rgba(255,255,255,0.15)';
179+ ctx.beginPath();
180+ ctx.moveTo(0, rollBottom + 0.5);
181+ ctx.lineTo(width, rollBottom + 0.5);
182+ ctx.stroke();
183+ if (activeTrack) {
184+ const color = trackColor(activeIdx);
185+ for (const n of activeTrack.notes) {
186+ const x = tickToX(song, n.tick);
187+ if (x < KEYS_W - 4 || x > width) continue;
188+ const bh = (n.velocity / 127) * (VEL_H - 8);
189+ ctx.fillStyle = color;
190+ ctx.globalAlpha = selection.has(n.id) ? 1 : 0.6;
191+ ctx.fillRect(x - 2, height - 2 - bh, 5, bh);
192+ }
193+ ctx.globalAlpha = 1;
194+ }
195+ ctx.fillStyle = 'rgba(255,255,255,0.4)';
196+ ctx.font = '10px system-ui';
197+ ctx.fillText('velocity', 6, rollBottom + 13);
198+
199+ // ruler
200+ ctx.fillStyle = '#22262e';
201+ ctx.fillRect(0, 0, width, RULER_H);
202+ for (const line of lines) {
203+ if (!line.isBar) continue;
204+ const x = tickToX(song, line.tick);
205+ ctx.strokeStyle = 'rgba(255,255,255,0.3)';
206+ ctx.beginPath();
207+ ctx.moveTo(x, RULER_H - 8);
208+ ctx.lineTo(x, RULER_H);
209+ ctx.stroke();
210+ ctx.fillStyle = 'rgba(255,255,255,0.6)';
211+ ctx.font = '10px system-ui';
212+ ctx.fillText(String(tm.barBeatAt(line.tick).bar), x + 3, RULER_H - 10);
213+ }
214+
215+ // key column
216+ for (let p = PITCH_MIN; p <= PITCH_MAX; p++) {
217+ const y = pitchToY(p);
218+ if (y + KEY_H < RULER_H || y > rollBottom) continue;
219+ const black = isBlackKey(p);
220+ ctx.fillStyle = black ? '#2a2d34' : '#e8e8ea';
221+ ctx.fillRect(0, Math.max(RULER_H, y), KEYS_W - 2, KEY_H - 0.5);
222+ if (p % 12 === 0) {
223+ ctx.fillStyle = '#333';
224+ ctx.font = '9px system-ui';
225+ ctx.fillText(noteName(p), 4, y + KEY_H - 3);
226+ }
227+ }
228+ ctx.fillStyle = '#16181d';
229+ ctx.fillRect(KEYS_W - 2, RULER_H, 2, rollBottom - RULER_H);
230+
231+ // playhead
232+ const px = tickToX(song, playTick);
233+ if (px >= KEYS_W) {
234+ ctx.strokeStyle = player.state === 'recording' ? '#ff5252' : '#ffd24a';
235+ ctx.beginPath();
236+ ctx.moveTo(px, 0);
237+ ctx.lineTo(px, height);
238+ ctx.stroke();
239+ }
240+ };
241+ raf = requestAnimationFrame(draw);
242+
243+ // --- interaction ---
244+
245+ const hitNote = (song: Song, x: number, y: number): Note | null => {
246+ const st = store.state;
247+ const track = song.tracks.find((t) => t.id === st.activeTrackId) ?? song.tracks[0];
248+ if (!track) return null;
249+ let hit: Note | null = null;
250+ for (const n of track.notes) {
251+ const nx = tickToX(song, n.tick);
252+ const w = Math.max(3, n.duration * pxPerTick(song) - 1);
253+ const ny = pitchToY(n.pitch);
254+ if (x >= nx - 1 && x <= nx + w + 1 && y >= ny && y < ny + KEY_H) hit = n;
255+ }
256+ return hit;
257+ };
258+
259+ const blip = (instrumentId: string, pitch: number) => {
260+ try {
261+ const voice = player.previewNoteOn(instrumentId, pitch, 100);
262+ setTimeout(() => voice.noteOff(), 180);
263+ } catch { /* audio not available */ }
264+ };
265+
266+ const activeInstrument = (song: Song): string => {
267+ const st = store.state;
268+ const track = song.tracks.find((t) => t.id === st.activeTrackId) ?? song.tracks[0];
269+ return track?.instrumentId ?? 'pluck';
270+ };
271+
272+ const effSnap = (ev: PointerEvent | MouseEvent): number => (ev.altKey ? 0 : store.snapTicks());
273+
274+ const onPointerDown = (ev: PointerEvent) => {
275+ const song = store.song();
276+ if (!song) return;
277+ const rect = canvas.getBoundingClientRect();
278+ const x = ev.clientX - rect.left;
279+ const y = ev.clientY - rect.top;
280+ const rollBottom = height - VEL_H;
281+ canvas.setPointerCapture(ev.pointerId);
282+
283+ if (y < RULER_H) {
284+ const sec = getTm(song).tickToSec(Math.max(0, xToTick(song, Math.max(KEYS_W, x))));
285+ if (player.state !== 'stopped') {
286+ store.seekTo(sec);
287+ drag.current = null;
288+ } else {
289+ player.seek(sec);
290+ drag.current = { mode: 'scrub' };
291+ }
292+ return;
293+ }
294+ if (x < KEYS_W && y < rollBottom) {
295+ const pitch = yToPitch(y);
296+ if (pitch < PITCH_MIN || pitch > PITCH_MAX) return;
297+ const voice = player.previewNoteOn(activeInstrument(song), pitch, 100);
298+ drag.current = { mode: 'keys', pitch, voice };
299+ return;
300+ }
301+ if (y >= rollBottom) {
302+ store.beginEdit();
303+ drag.current = { mode: 'velocity' };
304+ applyVelocity(x, y);
305+ return;
306+ }
307+
308+ const hit = hitNote(song, x, y);
309+ if (ev.button === 2) {
310+ if (hit) {
311+ store.mutateSong((s) => {
312+ for (const t of s.tracks) t.notes = t.notes.filter((n) => n.id !== hit.id);
313+ });
314+ store.setSelection(store.state.selection.filter((id) => id !== hit.id));
315+ }
316+ drag.current = null;
317+ return;
318+ }
319+ if (hit) {
320+ let sel = store.state.selection;
321+ if (!sel.includes(hit.id)) {
322+ sel = ev.shiftKey ? [...sel, hit.id] : [hit.id];
323+ store.setSelection(sel);
324+ }
325+ const track = song.tracks.find((t) => t.id === store.state.activeTrackId) ?? song.tracks[0];
326+ const origs = new Map<string, OrigNote>();
327+ for (const n of track.notes) if (sel.includes(n.id)) origs.set(n.id, { tick: n.tick, duration: n.duration, pitch: n.pitch });
328+ const nx = tickToX(song, hit.tick);
329+ const w = Math.max(3, hit.duration * pxPerTick(song) - 1);
330+ store.beginEdit();
331+ if (x > nx + w - 6 && w > 10) {
332+ drag.current = { mode: 'resize', startX: x, anchorId: hit.id, origs };
333+ } else {
334+ drag.current = { mode: 'move', startX: x, startY: y, anchorId: hit.id, origs, lastBlipPitch: hit.pitch };
335+ }
336+ return;
337+ }
338+ if (ev.shiftKey) {
339+ drag.current = { mode: 'marquee', x0: x, y0: y, x1: x, y1: y };
340+ return;
341+ }
342+ // create a note
343+ const snap = effSnap(ev);
344+ const tick = Math.max(0, snapValue(xToTick(song, x), snap, 'floor'));
345+ const pitch = yToPitch(y);
346+ if (pitch < PITCH_MIN || pitch > PITCH_MAX) return;
347+ const dur = lastCreateDur.current > 0 ? lastCreateDur.current : snap > 0 ? snap : Math.round(song.ppq / 4);
348+ const id = newId('n');
349+ store.beginEdit();
350+ store.editSong((s) => {
351+ const track = s.tracks.find((t) => t.id === store.state.activeTrackId) ?? s.tracks[0];
352+ track?.notes.push({ id, tick, duration: dur, pitch, velocity: 100 });
353+ });
354+ store.setSelection([id]);
355+ blip(activeInstrument(song), pitch);
356+ drag.current = { mode: 'create', id, startTick: tick };
357+ };
358+
359+ const applyVelocity = (x: number, y: number) => {
360+ const vel = Math.max(1, Math.min(127, Math.round(((height - 2 - y) / (VEL_H - 8)) * 127)));
361+ store.editSong((s) => {
362+ const track = s.tracks.find((t) => t.id === store.state.activeTrackId) ?? s.tracks[0];
363+ if (!track) return;
364+ for (const n of track.notes) {
365+ const nx = tickToX(s, n.tick);
366+ if (Math.abs(nx - x) <= 4) n.velocity = vel;
367+ }
368+ });
369+ };
370+
371+ const onPointerMove = (ev: PointerEvent) => {
372+ const d = drag.current;
373+ const song = store.song();
374+ if (!d || !song) return;
375+ const rect = canvas.getBoundingClientRect();
376+ const x = ev.clientX - rect.left;
377+ const y = ev.clientY - rect.top;
378+ if (d.mode === 'scrub') {
379+ const sec = getTm(song).tickToSec(Math.max(0, xToTick(song, Math.max(KEYS_W, x))));
380+ player.seek(sec);
381+ } else if (d.mode === 'keys') {
382+ const pitch = yToPitch(y);
383+ if (pitch !== d.pitch && pitch >= PITCH_MIN && pitch <= PITCH_MAX) {
384+ d.voice?.noteOff();
385+ d.voice = player.previewNoteOn(activeInstrument(song), pitch, 100);
386+ d.pitch = pitch;
387+ }
388+ } else if (d.mode === 'velocity') {
389+ applyVelocity(x, y);
390+ } else if (d.mode === 'marquee') {
391+ d.x1 = x;
392+ d.y1 = y;
393+ } else if (d.mode === 'create') {
394+ const snap = effSnap(ev);
395+ const raw = xToTick(song, x);
396+ const end = Math.max(d.startTick + MIN_DUR_TICKS, snapValue(raw, snap, 'ceil'));
397+ store.editSong((s) => {
398+ for (const t of s.tracks) {
399+ const n = t.notes.find((nn) => nn.id === d.id);
400+ if (n) n.duration = end - d.startTick;
401+ }
402+ });
403+ } else if (d.mode === 'move') {
404+ const snap = effSnap(ev);
405+ const anchor = d.origs.get(d.anchorId)!;
406+ const rawDelta = (x - d.startX) / pxPerTick(song);
407+ const delta = snapValue(anchor.tick + rawDelta, snap, 'round') - anchor.tick;
408+ const dPitch = -Math.round((y - d.startY) / KEY_H);
409+ store.editSong((s) => {
410+ for (const t of s.tracks) {
411+ for (const n of t.notes) {
412+ const o = d.origs.get(n.id);
413+ if (!o) continue;
414+ n.tick = Math.max(0, o.tick + delta);
415+ n.pitch = Math.max(0, Math.min(127, o.pitch + dPitch));
416+ }
417+ }
418+ });
419+ const newAnchorPitch = Math.max(0, Math.min(127, anchor.pitch + dPitch));
420+ if (newAnchorPitch !== d.lastBlipPitch) {
421+ d.lastBlipPitch = newAnchorPitch;
422+ blip(activeInstrument(song), newAnchorPitch);
423+ }
424+ } else if (d.mode === 'resize') {
425+ const snap = effSnap(ev);
426+ const anchor = d.origs.get(d.anchorId)!;
427+ const rawDelta = (x - d.startX) / pxPerTick(song);
428+ const newEnd = snapValue(anchor.tick + anchor.duration + rawDelta, snap, 'round');
429+ const delta = newEnd - (anchor.tick + anchor.duration);
430+ store.editSong((s) => {
431+ for (const t of s.tracks) {
432+ for (const n of t.notes) {
433+ const o = d.origs.get(n.id);
434+ if (!o) continue;
435+ n.duration = Math.max(MIN_DUR_TICKS, o.duration + delta);
436+ }
437+ }
438+ });
439+ }
440+ };
441+
442+ const onPointerUp = () => {
443+ const d = drag.current;
444+ const song = store.song();
445+ drag.current = null;
446+ if (!d || !song) return;
447+ if (d.mode === 'keys') {
448+ d.voice?.noteOff();
449+ } else if (d.mode === 'marquee') {
450+ const track = song.tracks.find((t) => t.id === store.state.activeTrackId) ?? song.tracks[0];
451+ if (!track) return;
452+ const x0 = Math.min(d.x0, d.x1);
453+ const x1 = Math.max(d.x0, d.x1);
454+ const y0 = Math.min(d.y0, d.y1);
455+ const y1 = Math.max(d.y0, d.y1);
456+ const ids: string[] = [];
457+ for (const n of track.notes) {
458+ const nx = tickToX(song, n.tick);
459+ const w = Math.max(3, n.duration * pxPerTick(song) - 1);
460+ const ny = pitchToY(n.pitch);
461+ if (nx + w >= x0 && nx <= x1 && ny + KEY_H >= y0 && ny <= y1) ids.push(n.id);
462+ }
463+ store.setSelection(ids);
464+ } else if (d.mode === 'create') {
465+ for (const t of song.tracks) {
466+ const n = t.notes.find((nn) => nn.id === d.id);
467+ if (n) lastCreateDur.current = n.duration;
468+ }
469+ store.endEdit();
470+ } else if (d.mode === 'move' || d.mode === 'resize' || d.mode === 'velocity') {
471+ store.endEdit();
472+ }
473+ };
474+
475+ const onWheel = (ev: WheelEvent) => {
476+ ev.preventDefault();
477+ const song = store.song();
478+ if (!song) return;
479+ const rect = canvas.getBoundingClientRect();
480+ const x = ev.clientX - rect.left;
481+ if (ev.ctrlKey || ev.metaKey) {
482+ const tickAtMouse = xToTick(song, x);
483+ const factor = Math.exp(-ev.deltaY * 0.0018);
484+ view.current.pxPerBeat = Math.max(12, Math.min(700, view.current.pxPerBeat * factor));
485+ view.current.scrollX = Math.max(0, tickAtMouse * pxPerTick(song) - (x - KEYS_W));
486+ } else if (ev.shiftKey) {
487+ view.current.scrollX = Math.max(0, view.current.scrollX + ev.deltaY);
488+ } else {
489+ const rollH = (PITCH_MAX - PITCH_MIN + 1) * KEY_H;
490+ const viewH = Math.max(50, height - VEL_H - RULER_H);
491+ view.current.scrollY = Math.max(0, Math.min(rollH - viewH, view.current.scrollY + ev.deltaY));
492+ const maxX = Math.max(0, (songEndTick(song) + song.ppq * 64) * pxPerTick(song) - (width - KEYS_W));
493+ view.current.scrollX = Math.max(0, Math.min(maxX, view.current.scrollX + ev.deltaX));
494+ }
495+ };
496+
497+ canvas.addEventListener('pointerdown', onPointerDown);
498+ canvas.addEventListener('pointermove', onPointerMove);
499+ canvas.addEventListener('pointerup', onPointerUp);
500+ canvas.addEventListener('pointercancel', onPointerUp);
501+ canvas.addEventListener('wheel', onWheel, { passive: false });
502+ canvas.addEventListener('contextmenu', (e) => e.preventDefault());
503+
504+ return () => {
505+ cancelAnimationFrame(raf);
506+ ro.disconnect();
507+ canvas.removeEventListener('pointerdown', onPointerDown);
508+ canvas.removeEventListener('pointermove', onPointerMove);
509+ canvas.removeEventListener('pointerup', onPointerUp);
510+ canvas.removeEventListener('pointercancel', onPointerUp);
511+ canvas.removeEventListener('wheel', onWheel);
512+ };
513+ }, []);
514+
515+ return (
516+ <div className="pianoroll" ref={containerRef}>
517+ <canvas ref={canvasRef} />
518+ </div>
519+ );
520+}
src/components/TrackList.tsxadded+78−0View file
@@ -0,0 +1,78 @@
1+import { store, useApp } from '../state/store.ts';
2+import { listInstruments } from '../instruments/registry.ts';
3+import { trackColor } from './colors.ts';
4+
5+export function TrackList() {
6+ const app = useApp();
7+ const song = app.rec?.song ?? null;
8+ if (!song) return <div className="tracklist" />;
9+ const instruments = listInstruments();
10+ return (
11+ <div className="tracklist">
12+ {song.tracks.map((t, i) => {
13+ const active = t.id === app.activeTrackId;
14+ const armed = t.id === app.armedTrackId;
15+ return (
16+ <div key={t.id} className={`trackrow ${active ? 'active' : ''}`} onClick={() => store.setActiveTrack(t.id)}>
17+ <div className="trackrow-top">
18+ <span className="chip" style={{ background: trackColor(i) }} />
19+ <input
20+ className="trackname"
21+ value={t.name}
22+ onChange={(e) => store.updateTrack(t.id, { name: e.target.value }, false)}
23+ onBlur={() => store.endEdit()}
24+ onClick={(e) => e.stopPropagation()}
25+ />
26+ <button
27+ className="mini danger"
28+ title="Delete track"
29+ onClick={(e) => { e.stopPropagation(); store.removeTrack(t.id); }}
30+ >✕</button>
31+ </div>
32+ <div className="trackrow-bottom">
33+ <select
34+ value={t.instrumentId}
35+ onChange={(e) => store.updateTrack(t.id, { instrumentId: e.target.value })}
36+ onClick={(e) => e.stopPropagation()}
37+ title="Instrument"
38+ >
39+ {instruments.map((ins) => (
40+ <option key={ins.id} value={ins.id}>{ins.name}</option>
41+ ))}
42+ </select>
43+ <button
44+ className={`mini ${t.mute ? 'toggled' : ''}`}
45+ title="Mute"
46+ onClick={(e) => { e.stopPropagation(); store.updateTrack(t.id, { mute: !t.mute }, false); store.endEdit(); }}
47+ >M</button>
48+ <button
49+ className={`mini ${t.solo ? 'toggled' : ''}`}
50+ title="Solo"
51+ onClick={(e) => { e.stopPropagation(); store.updateTrack(t.id, { solo: !t.solo }, false); store.endEdit(); }}
52+ >S</button>
53+ <button
54+ className={`mini rec ${armed ? 'toggled' : ''}`}
55+ title="Arm for recording"
56+ onClick={(e) => { e.stopPropagation(); store.setArmedTrack(armed ? null : t.id); }}
57+ >R</button>
58+ </div>
59+ <div className="trackrow-vol">
60+ <input
61+ type="range"
62+ min={0}
63+ max={1.5}
64+ step={0.01}
65+ value={t.volume}
66+ title={`Volume ${(t.volume * 100).toFixed(0)}%`}
67+ onChange={(e) => store.updateTrack(t.id, { volume: Number(e.target.value) }, false)}
68+ onClick={(e) => e.stopPropagation()}
69+ />
70+ <span className="notecount">{t.notes.length} notes</span>
71+ </div>
72+ </div>
73+ );
74+ })}
75+ <button className="addtrack" onClick={() => store.addTrack()}>+ Add track</button>
76+ </div>
77+ );
78+}
src/components/Transport.tsxadded+85−0View file
@@ -0,0 +1,85 @@
1+import { useMemo } from 'react';
2+import { store, useApp, usePlayerVersion } from '../state/store.ts';
3+import { player } from '../audio/player.ts';
4+import { TempoMap } from '../midi/time.ts';
5+
6+const 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+];
15+
16+export 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]);
21+
22+ if (!song || !tm) return <div className="transport" />;
23+
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;
32+
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+}
src/components/colors.tsadded+5−0View file
@@ -0,0 +1,5 @@
1+export const TRACK_COLORS = ['#5aa9e6', '#e6a15a', '#7ce65a', '#e65a8f', '#b45ae6', '#5ae6d0', '#e6d05a', '#e65a5a'];
2+
3+export function trackColor(index: number): string {
4+ return TRACK_COLORS[((index % TRACK_COLORS.length) + TRACK_COLORS.length) % TRACK_COLORS.length];
5+}
src/input/keyboardPiano.tsadded+89−0View file
@@ -0,0 +1,89 @@
1+import type { NoteInputEvent } from './webmidi.ts';
2+
3+// Typing-keyboard piano: the a..' row plays white keys from C, the row
4+// above plays the black keys, z/x shift the octave. Active only when
5+// enabled and when focus is not in a text field.
6+
7+const KEY_TO_SEMITONE: Record<string, number> = {
8+ a: 0, w: 1, s: 2, e: 3, d: 4, f: 5, t: 6, g: 7, y: 8, h: 9, u: 10, j: 11,
9+ k: 12, o: 13, l: 14, p: 15, ';': 16, "'": 17,
10+};
11+
12+type Listener = (e: NoteInputEvent) => void;
13+
14+class KeyboardPiano {
15+ enabled = false;
16+ octave = 4; // 'a' plays C4 by default (MIDI 60 at octave 4)
17+ private listeners = new Set<Listener>();
18+ private stateListeners = new Set<() => void>();
19+ private held = new Map<string, number>();
20+ private attached = false;
21+
22+ subscribe(cb: Listener): () => void {
23+ this.listeners.add(cb);
24+ return () => this.listeners.delete(cb);
25+ }
26+
27+ subscribeState(cb: () => void): () => void {
28+ this.stateListeners.add(cb);
29+ return () => this.stateListeners.delete(cb);
30+ }
31+
32+ private emitState() { for (const cb of this.stateListeners) cb(); }
33+
34+ setEnabled(v: boolean) {
35+ this.enabled = v;
36+ if (!v) this.releaseAll();
37+ this.attach();
38+ this.emitState();
39+ }
40+
41+ private attach() {
42+ if (this.attached) return;
43+ this.attached = true;
44+ window.addEventListener('keydown', this.onKeyDown);
45+ window.addEventListener('keyup', this.onKeyUp);
46+ window.addEventListener('blur', () => this.releaseAll());
47+ }
48+
49+ private inTextField(): boolean {
50+ const el = document.activeElement;
51+ return !!el && (el.tagName === 'INPUT' || el.tagName === 'TEXTAREA' || (el as HTMLElement).isContentEditable);
52+ }
53+
54+ private onKeyDown = (ev: KeyboardEvent) => {
55+ if (!this.enabled || ev.repeat || ev.ctrlKey || ev.metaKey || ev.altKey || this.inTextField()) return;
56+ const key = ev.key.toLowerCase();
57+ if (key === 'z') { this.octave = Math.max(0, this.octave - 1); this.emitState(); return; }
58+ if (key === 'x') { this.octave = Math.min(8, this.octave + 1); this.emitState(); return; }
59+ const semi = KEY_TO_SEMITONE[key];
60+ if (semi === undefined || this.held.has(key)) return;
61+ const pitch = (this.octave + 1) * 12 + semi;
62+ if (pitch > 127) return;
63+ ev.preventDefault();
64+ this.held.set(key, pitch);
65+ this.emit({ on: true, pitch, velocity: 100, source: 'keyboard' });
66+ };
67+
68+ private onKeyUp = (ev: KeyboardEvent) => {
69+ const key = ev.key.toLowerCase();
70+ const pitch = this.held.get(key);
71+ if (pitch === undefined) return;
72+ this.held.delete(key);
73+ this.emit({ on: false, pitch, velocity: 0, source: 'keyboard' });
74+ };
75+
76+ private releaseAll() {
77+ for (const [key, pitch] of this.held) {
78+ void key;
79+ this.emit({ on: false, pitch, velocity: 0, source: 'keyboard' });
80+ }
81+ this.held.clear();
82+ }
83+
84+ private emit(e: NoteInputEvent) {
85+ for (const cb of this.listeners) cb(e);
86+ }
87+}
88+
89+export const keyboardPiano = new KeyboardPiano();
src/input/recorder.tsadded+49−0View file
@@ -0,0 +1,49 @@
1+import type { NoteInputEvent } from './webmidi.ts';
2+
3+// Captures note input against the transport clock while recording. Times
4+// are seconds on the song timeline; the store converts to ticks when the
5+// take is committed.
6+
7+export interface CapturedNote {
8+ startSec: number;
9+ endSec: number;
10+ pitch: number;
11+ velocity: number; // 1..127
12+}
13+
14+export class Recorder {
15+ private active = false;
16+ private open = new Map<number, { startSec: number; velocity: number }>();
17+ private captured: CapturedNote[] = [];
18+
19+ get isActive(): boolean { return this.active; }
20+
21+ start(): void {
22+ this.active = true;
23+ this.open.clear();
24+ this.captured = [];
25+ }
26+
27+ noteEvent(e: NoteInputEvent, songSec: number): void {
28+ if (!this.active) return;
29+ if (e.on) {
30+ this.open.set(e.pitch, { startSec: songSec, velocity: e.velocity });
31+ } else {
32+ const o = this.open.get(e.pitch);
33+ if (!o) return;
34+ this.open.delete(e.pitch);
35+ this.captured.push({ startSec: o.startSec, endSec: Math.max(o.startSec + 0.03, songSec), pitch: e.pitch, velocity: o.velocity });
36+ }
37+ }
38+
39+ stop(atSongSec: number): CapturedNote[] {
40+ for (const [pitch, o] of this.open) {
41+ this.captured.push({ startSec: o.startSec, endSec: Math.max(o.startSec + 0.03, atSongSec), pitch, velocity: o.velocity });
42+ }
43+ this.open.clear();
44+ this.active = false;
45+ const out = this.captured.sort((a, b) => a.startSec - b.startSec);
46+ this.captured = [];
47+ return out;
48+ }
49+}
src/input/webmidi.tsadded+86−0View file
@@ -0,0 +1,86 @@
1+// Web MIDI input manager: enumerates inputs, follows hot-plug through
2+// statechange, and forwards note on/off events from the selected input
3+// (or from every input) to subscribers.
4+
5+export interface NoteInputEvent {
6+ on: boolean;
7+ pitch: number;
8+ velocity: number; // 1..127 for on
9+ source: string;
10+}
11+
12+export interface MidiState {
13+ supported: boolean;
14+ requested: boolean;
15+ error: string | null;
16+ inputs: { id: string; name: string }[];
17+ selectedId: string; // 'all' or an input id
18+}
19+
20+type Listener = (e: NoteInputEvent) => void;
21+
22+class MidiManager {
23+ private access: MIDIAccess | null = null;
24+ private listeners = new Set<Listener>();
25+ private stateListeners = new Set<() => void>();
26+ private state: MidiState = { supported: typeof navigator !== 'undefined' && 'requestMIDIAccess' in navigator, requested: false, error: null, inputs: [], selectedId: 'all' };
27+
28+ getState(): MidiState { return this.state; }
29+
30+ subscribeState(cb: () => void): () => void {
31+ this.stateListeners.add(cb);
32+ return () => this.stateListeners.delete(cb);
33+ }
34+
35+ subscribe(cb: Listener): () => void {
36+ this.listeners.add(cb);
37+ return () => this.listeners.delete(cb);
38+ }
39+
40+ private setState(patch: Partial<MidiState>) {
41+ this.state = { ...this.state, ...patch };
42+ for (const cb of this.stateListeners) cb();
43+ }
44+
45+ async request(): Promise<void> {
46+ if (!this.state.supported || this.access) return;
47+ this.setState({ requested: true });
48+ try {
49+ this.access = await navigator.requestMIDIAccess({ sysex: false });
50+ this.access.onstatechange = () => this.refresh();
51+ this.refresh();
52+ } catch (err) {
53+ this.setState({ error: err instanceof Error ? err.message : String(err) });
54+ }
55+ }
56+
57+ select(id: string) {
58+ this.setState({ selectedId: id });
59+ this.refresh();
60+ }
61+
62+ private refresh() {
63+ if (!this.access) return;
64+ const inputs: { id: string; name: string }[] = [];
65+ for (const input of this.access.inputs.values()) {
66+ inputs.push({ id: input.id, name: input.name ?? input.id });
67+ const active = this.state.selectedId === 'all' || this.state.selectedId === input.id;
68+ input.onmidimessage = active ? (ev: MIDIMessageEvent) => this.onMessage(input.name ?? input.id, ev) : null;
69+ }
70+ this.setState({ inputs });
71+ }
72+
73+ private onMessage(source: string, ev: MIDIMessageEvent) {
74+ const d = ev.data;
75+ if (!d || d.length < 3) return;
76+ const kind = d[0] >> 4;
77+ if (kind === 0x9 && d[2] > 0) this.emit({ on: true, pitch: d[1], velocity: d[2], source });
78+ else if (kind === 0x8 || (kind === 0x9 && d[2] === 0)) this.emit({ on: false, pitch: d[1], velocity: 0, source });
79+ }
80+
81+ private emit(e: NoteInputEvent) {
82+ for (const cb of this.listeners) cb(e);
83+ }
84+}
85+
86+export const midiManager = new MidiManager();
src/instruments/builtins.tsadded+71−0View file
@@ -0,0 +1,71 @@
1+import type { Instrument, LiveVoice, NoteSpec, RenderRequest } from './types.ts';
2+import { drumNote, epianoNote, leadNote, pluckNote } from './dsp.ts';
3+
4+// Built-in instruments, each constructed from a pure per-note DSP function.
5+// The render path sums per-note buffers; the live path plays one note's
6+// buffer through a gain node whose release implements noteOff. A custom
7+// instrument with its own rendering logic does not have to go through
8+// this helper; it only has to satisfy the Instrument interface.
9+
10+export type NoteDsp = (sr: number, pitch: number, velocity: number, durationSec: number) => Float32Array;
11+
12+interface DspOpts {
13+ sustains: boolean; // if true, live voices are rendered long and gated by noteOff
14+ liveDurationSec?: number;
15+}
16+
17+export function instrumentFromNoteDsp(id: string, name: string, dsp: NoteDsp, opts: DspOpts): Instrument {
18+ return {
19+ id,
20+ name,
21+ render(req: RenderRequest): Float32Array {
22+ const out = new Float32Array(req.lengthSamples);
23+ for (const note of req.notes) {
24+ const buf = dsp(req.sampleRate, note.pitch, note.velocity, note.durationSec);
25+ const off = Math.round(note.timeSec * req.sampleRate);
26+ const end = Math.min(out.length, off + buf.length);
27+ for (let i = Math.max(0, off); i < end; i++) out[i] += buf[i - off];
28+ }
29+ return out;
30+ },
31+ noteOn(ctx, dest, pitch, velocity, whenSec): LiveVoice {
32+ const dur = opts.sustains ? (opts.liveDurationSec ?? 20) : 0.25;
33+ const data = dsp(ctx.sampleRate, pitch, velocity, dur);
34+ const buffer = ctx.createBuffer(1, data.length, ctx.sampleRate);
35+ buffer.copyToChannel(data as Float32Array<ArrayBuffer>, 0);
36+ const src = ctx.createBufferSource();
37+ src.buffer = buffer;
38+ const gain = ctx.createGain();
39+ src.connect(gain);
40+ gain.connect(dest);
41+ src.start(whenSec);
42+ let done = false;
43+ const finish = (fadeSec: number) => {
44+ if (done) return;
45+ done = true;
46+ const t = Math.max(ctx.currentTime, whenSec);
47+ gain.gain.setValueAtTime(gain.gain.value, t);
48+ gain.gain.setTargetAtTime(0, t, fadeSec / 4);
49+ src.stop(t + fadeSec + 0.1);
50+ };
51+ src.onended = () => { done = true; };
52+ return {
53+ noteOff: () => { if (opts.sustains) finish(0.15); },
54+ cut: () => finish(0.02),
55+ };
56+ },
57+ };
58+}
59+
60+export const builtinInstruments: Instrument[] = [
61+ instrumentFromNoteDsp('pluck', 'Pluck (Karplus-Strong)', pluckNote, { sustains: true, liveDurationSec: 3 }),
62+ instrumentFromNoteDsp('epiano', 'E-Piano (FM)', epianoNote, { sustains: true, liveDurationSec: 8 }),
63+ instrumentFromNoteDsp('lead', 'Lead (square)', leadNote, { sustains: true, liveDurationSec: 20 }),
64+ instrumentFromNoteDsp('drumkit', 'Drum kit (GM)', drumNote, { sustains: false }),
65+];
66+
67+export function noteSpecVelocity(velocity127: number): number {
68+ return Math.max(0, Math.min(1, velocity127 / 127));
69+}
70+
71+export type { NoteSpec };
src/instruments/dsp.tsadded+165−0View file
@@ -0,0 +1,165 @@
1+// Pure per-note synthesis. Every function here is deterministic (noise
2+// comes from a PRNG seeded by pitch) and free of Web Audio, so the node
3+// test harness can hash the actual audio.
4+
5+export function midiToFreq(pitch: number): number {
6+ return 440 * Math.pow(2, (pitch - 69) / 12);
7+}
8+
9+export function mulberry32(seed: number): () => number {
10+ let a = seed >>> 0;
11+ return () => {
12+ a |= 0;
13+ a = (a + 0x6d2b79f5) | 0;
14+ let t = Math.imul(a ^ (a >>> 15), 1 | a);
15+ t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
16+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
17+ };
18+}
19+
20+// Karplus-Strong plucked string. Rings past the nominal duration with a
21+// release envelope so a short note still sounds like a pluck.
22+export function pluckNote(sr: number, pitch: number, velocity: number, durationSec: number): Float32Array {
23+ const f0 = midiToFreq(pitch);
24+ const period = Math.max(2, Math.round(sr / f0));
25+ const tail = 1.2;
26+ const n = Math.max(period + 1, Math.floor(sr * (durationSec + tail)));
27+ const out = new Float32Array(n);
28+ const delay = new Float32Array(period);
29+ const rng = mulberry32(0x9e3779b9 ^ pitch);
30+ for (let i = 0; i < period; i++) delay[i] = rng() * 2 - 1;
31+ const g = 0.996;
32+ let idx = 0;
33+ for (let i = 0; i < n; i++) {
34+ const cur = delay[idx];
35+ const nxt = delay[(idx + 1) % period];
36+ out[i] = cur;
37+ delay[idx] = g * 0.5 * (cur + nxt);
38+ idx = (idx + 1) % period;
39+ }
40+ const relStart = Math.floor(durationSec * sr);
41+ for (let i = 0; i < n; i++) {
42+ let env = 1;
43+ if (i > relStart) env = Math.exp(-(i - relStart) / (0.12 * sr));
44+ out[i] *= env;
45+ }
46+ scale(out, 0.6 * velocity);
47+ return out;
48+}
49+
50+// Two-operator FM electric piano: octave modulator with a decaying index.
51+export function epianoNote(sr: number, pitch: number, velocity: number, durationSec: number): Float32Array {
52+ const f0 = midiToFreq(pitch);
53+ const tail = 0.8;
54+ const n = Math.floor(sr * (durationSec + tail));
55+ const out = new Float32Array(n);
56+ const wc = (2 * Math.PI * f0) / sr;
57+ const wm = 2 * wc;
58+ const relStart = durationSec * sr;
59+ for (let i = 0; i < n; i++) {
60+ const t = i / sr;
61+ const idxEnv = (1.2 + 2.5 * velocity) * Math.exp(-t * 4);
62+ let env = Math.min(1, i / (0.002 * sr)) * Math.exp(-t * 1.8);
63+ if (i > relStart) env *= Math.exp(-(i - relStart) / (0.07 * sr));
64+ out[i] = env * Math.sin(wc * i + idxEnv * Math.sin(wm * i));
65+ }
66+ scale(out, 0.5 * velocity);
67+ return out;
68+}
69+
70+// Square-ish lead: summed odd harmonics through a one-pole lowpass whose
71+// cutoff falls with time, with an ADSR gated by the note duration.
72+export function leadNote(sr: number, pitch: number, velocity: number, durationSec: number): Float32Array {
73+ const f0 = midiToFreq(pitch);
74+ const tail = 0.35;
75+ const n = Math.floor(sr * (durationSec + tail));
76+ const out = new Float32Array(n);
77+ const w = (2 * Math.PI * f0) / sr;
78+ const nyq = sr / 2;
79+ let y = 0;
80+ const relStart = durationSec * sr;
81+ for (let i = 0; i < n; i++) {
82+ const t = i / sr;
83+ let x = 0;
84+ for (let k = 1; k <= 15; k += 2) {
85+ if (f0 * k > nyq * 0.9) break;
86+ x += Math.sin(w * k * i) / k;
87+ }
88+ const cutoff = 500 + (1500 + 5000 * velocity) * Math.exp(-t * 3);
89+ const a = 1 - Math.exp((-2 * Math.PI * cutoff) / sr);
90+ y += a * (x - y);
91+ const atk = Math.min(1, i / (0.005 * sr));
92+ let env = atk * (0.7 + 0.3 * Math.exp(-t * 6));
93+ if (i > relStart) env *= Math.exp(-(i - relStart) / (0.06 * sr));
94+ out[i] = y * env;
95+ }
96+ scale(out, 0.45 * velocity);
97+ return out;
98+}
99+
100+// General MIDI drum kit, keyed by pitch. Duration is ignored: a struck
101+// drum runs to its own decay.
102+export function drumNote(sr: number, pitch: number, velocity: number, _durationSec: number): Float32Array {
103+ const rng = mulberry32(0x51ed270b ^ pitch);
104+ const make = (len: number, fn: (t: number, i: number, noise: () => number) => number) => {
105+ const n = Math.floor(sr * len);
106+ const out = new Float32Array(n);
107+ for (let i = 0; i < n; i++) out[i] = fn(i / sr, i, () => rng() * 2 - 1);
108+ return out;
109+ };
110+ let buf: Float32Array;
111+ if (pitch === 35 || pitch === 36) {
112+ // kick: falling sine sweep
113+ let phase = 0;
114+ buf = make(0.5, (t) => {
115+ const f = 40 + 120 * Math.exp(-t * 28);
116+ phase += (2 * Math.PI * f) / sr;
117+ return Math.sin(phase) * Math.exp(-t * 9);
118+ });
119+ } else if (pitch === 38 || pitch === 40) {
120+ // snare: tone plus noise burst
121+ buf = make(0.3, (t, i, noise) => Math.sin((2 * Math.PI * 190 * i) / sr) * 0.4 * Math.exp(-t * 30) + noise() * 0.9 * Math.exp(-t * 18));
122+ } else if (pitch === 39) {
123+ // clap
124+ buf = make(0.25, (t, _i, noise) => noise() * (Math.exp(-t * 40) + 0.6 * Math.exp(-Math.max(0, t - 0.012) * 40) + 0.35 * Math.exp(-Math.max(0, t - 0.024) * 30)));
125+ } else if (pitch === 42 || pitch === 44) {
126+ // closed hat: highpassed noise
127+ buf = hp(make(0.1, (t, _i, noise) => noise() * Math.exp(-t * 60)));
128+ } else if (pitch === 46) {
129+ // open hat
130+ buf = hp(make(0.6, (t, _i, noise) => noise() * Math.exp(-t * 8)));
131+ } else if (pitch === 49 || pitch === 57 || pitch === 55 || pitch === 52) {
132+ // crashes
133+ buf = hp(make(1.6, (t, _i, noise) => noise() * Math.exp(-t * 3)));
134+ } else if (pitch === 51 || pitch === 59 || pitch === 53) {
135+ // ride
136+ buf = hp(make(1.0, (t, i, noise) => noise() * 0.5 * Math.exp(-t * 5) + Math.sin((2 * Math.PI * 5200 * i) / sr) * 0.1 * Math.exp(-t * 6)));
137+ } else if (pitch >= 41 && pitch <= 50) {
138+ // toms: pitch-mapped sweep
139+ const f0 = 70 + (pitch - 41) * 14;
140+ let phase = 0;
141+ buf = make(0.45, (t) => {
142+ const f = f0 * (1 + 0.6 * Math.exp(-t * 20));
143+ phase += (2 * Math.PI * f) / sr;
144+ return Math.sin(phase) * Math.exp(-t * 11);
145+ });
146+ } else {
147+ // default percussion tick
148+ buf = make(0.12, (t, _i, noise) => noise() * Math.exp(-t * 45));
149+ }
150+ scale(buf, 0.7 * velocity);
151+ return buf;
152+}
153+
154+function hp(buf: Float32Array): Float32Array {
155+ let lp = 0;
156+ for (let i = 0; i < buf.length; i++) {
157+ lp += 0.15 * (buf[i] - lp);
158+ buf[i] = buf[i] - lp;
159+ }
160+ return buf;
161+}
162+
163+function scale(buf: Float32Array, s: number) {
164+ for (let i = 0; i < buf.length; i++) buf[i] *= s;
165+}
src/instruments/registry.tsadded+23−0View file
@@ -0,0 +1,23 @@
1+import type { Instrument } from './types.ts';
2+import { builtinInstruments } from './builtins.ts';
3+
4+// The instrument registry. Custom instruments (a GPU physical model, a
5+// sampler, anything implementing Instrument) are added with
6+// registerInstrument, typically at startup; tracks refer to instruments by
7+// id, so an unknown id falls back to the first builtin rather than
8+// failing.
9+
10+const instruments = new Map<string, Instrument>();
11+for (const ins of builtinInstruments) instruments.set(ins.id, ins);
12+
13+export function registerInstrument(ins: Instrument): void {
14+ instruments.set(ins.id, ins);
15+}
16+
17+export function getInstrument(id: string): Instrument {
18+ return instruments.get(id) ?? builtinInstruments[0];
19+}
20+
21+export function listInstruments(): Instrument[] {
22+ return [...instruments.values()];
23+}
src/instruments/types.tsadded+42−0View file
@@ -0,0 +1,42 @@
1+// The instrument extension point. An instrument turns note events into
2+// audio in two ways:
3+//
4+// - render(): the offline path. Given every note with times in seconds,
5+// produce one mono buffer for the whole track. This is what playback
6+// and WAV export use, and it may be async and arbitrarily expensive
7+// (a GPU physical model, a worker, a remote call). Results are cached
8+// per track and invalidated by note or instrument changes.
9+//
10+// - noteOn(): the optional live path, used for low-latency monitoring
11+// while recording and for previewing notes from the piano roll and
12+// keyboard. An instrument without it is still fully usable; monitoring
13+// falls back to a small default click.
14+//
15+// To add a custom instrument, implement this interface and register it in
16+// registry.ts. Nothing else in the application knows what an instrument is
17+// made of.
18+
19+export interface NoteSpec {
20+ timeSec: number;
21+ durationSec: number;
22+ pitch: number; // MIDI note number
23+ velocity: number; // 0..1
24+}
25+
26+export interface RenderRequest {
27+ notes: NoteSpec[];
28+ sampleRate: number;
29+ lengthSamples: number;
30+}
31+
32+export interface LiveVoice {
33+ noteOff(): void; // begin release
34+ cut(): void; // stop immediately
35+}
36+
37+export interface Instrument {
38+ id: string;
39+ name: string;
40+ render(req: RenderRequest): Float32Array | Promise<Float32Array>;
41+ noteOn?(ctx: BaseAudioContext, dest: AudioNode, pitch: number, velocity: number, whenSec: number): LiveVoice;
42+}
src/main.tsxadded+7−0View file
@@ -0,0 +1,7 @@
1+import { createRoot } from 'react-dom/client';
2+import { App } from './components/App.tsx';
3+import { store } from './state/store.ts';
4+import './styles.css';
5+
6+void store.init();
7+createRoot(document.getElementById('root')!).render(<App />);
src/midi/gm.tsadded+40−0View file
@@ -0,0 +1,40 @@
1+// General MIDI program names, used to label imported tracks.
2+export const GM_PROGRAMS: string[] = [
3+ 'Acoustic Grand Piano', 'Bright Acoustic Piano', 'Electric Grand Piano', 'Honky-tonk Piano',
4+ 'Electric Piano 1', 'Electric Piano 2', 'Harpsichord', 'Clavinet',
5+ 'Celesta', 'Glockenspiel', 'Music Box', 'Vibraphone',
6+ 'Marimba', 'Xylophone', 'Tubular Bells', 'Dulcimer',
7+ 'Drawbar Organ', 'Percussive Organ', 'Rock Organ', 'Church Organ',
8+ 'Reed Organ', 'Accordion', 'Harmonica', 'Tango Accordion',
9+ 'Acoustic Guitar (nylon)', 'Acoustic Guitar (steel)', 'Electric Guitar (jazz)', 'Electric Guitar (clean)',
10+ 'Electric Guitar (muted)', 'Overdriven Guitar', 'Distortion Guitar', 'Guitar Harmonics',
11+ 'Acoustic Bass', 'Electric Bass (finger)', 'Electric Bass (pick)', 'Fretless Bass',
12+ 'Slap Bass 1', 'Slap Bass 2', 'Synth Bass 1', 'Synth Bass 2',
13+ 'Violin', 'Viola', 'Cello', 'Contrabass',
14+ 'Tremolo Strings', 'Pizzicato Strings', 'Orchestral Harp', 'Timpani',
15+ 'String Ensemble 1', 'String Ensemble 2', 'Synth Strings 1', 'Synth Strings 2',
16+ 'Choir Aahs', 'Voice Oohs', 'Synth Voice', 'Orchestra Hit',
17+ 'Trumpet', 'Trombone', 'Tuba', 'Muted Trumpet',
18+ 'French Horn', 'Brass Section', 'Synth Brass 1', 'Synth Brass 2',
19+ 'Soprano Sax', 'Alto Sax', 'Tenor Sax', 'Baritone Sax',
20+ 'Oboe', 'English Horn', 'Bassoon', 'Clarinet',
21+ 'Piccolo', 'Flute', 'Recorder', 'Pan Flute',
22+ 'Blown Bottle', 'Shakuhachi', 'Whistle', 'Ocarina',
23+ 'Lead 1 (square)', 'Lead 2 (sawtooth)', 'Lead 3 (calliope)', 'Lead 4 (chiff)',
24+ 'Lead 5 (charang)', 'Lead 6 (voice)', 'Lead 7 (fifths)', 'Lead 8 (bass + lead)',
25+ 'Pad 1 (new age)', 'Pad 2 (warm)', 'Pad 3 (polysynth)', 'Pad 4 (choir)',
26+ 'Pad 5 (bowed)', 'Pad 6 (metallic)', 'Pad 7 (halo)', 'Pad 8 (sweep)',
27+ 'FX 1 (rain)', 'FX 2 (soundtrack)', 'FX 3 (crystal)', 'FX 4 (atmosphere)',
28+ 'FX 5 (brightness)', 'FX 6 (goblins)', 'FX 7 (echoes)', 'FX 8 (sci-fi)',
29+ 'Sitar', 'Banjo', 'Shamisen', 'Koto',
30+ 'Kalimba', 'Bag pipe', 'Fiddle', 'Shanai',
31+ 'Tinkle Bell', 'Agogo', 'Steel Drums', 'Woodblock',
32+ 'Taiko Drum', 'Melodic Tom', 'Synth Drum', 'Reverse Cymbal',
33+ 'Guitar Fret Noise', 'Breath Noise', 'Seashore', 'Bird Tweet',
34+ 'Telephone Ring', 'Helicopter', 'Applause', 'Gunshot',
35+];
36+
37+export function noteName(pitch: number): string {
38+ const names = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B'];
39+ return `${names[((pitch % 12) + 12) % 12]}${Math.floor(pitch / 12) - 1}`;
40+}
src/midi/ops.tsadded+21−0View file
@@ -0,0 +1,21 @@
1+import type { Note } from '../types.ts';
2+
3+// Pure note-list operations used by the editor; kept free of DOM and state
4+// so the node test harness can exercise them directly.
5+
6+export function quantizeNotes(notes: Note[], gridTicks: number, ids?: Set<string>): Note[] {
7+ if (gridTicks <= 0) return notes;
8+ return notes.map((n) => {
9+ if (ids && !ids.has(n.id)) return n;
10+ const tick = Math.max(0, Math.round(n.tick / gridTicks) * gridTicks);
11+ return { ...n, tick };
12+ });
13+}
14+
15+export function transposeNotes(notes: Note[], semitones: number, ids?: Set<string>): Note[] {
16+ return notes.map((n) => {
17+ if (ids && !ids.has(n.id)) return n;
18+ const pitch = Math.max(0, Math.min(127, n.pitch + semitones));
19+ return { ...n, pitch };
20+ });
21+}
src/midi/parse.tsadded+169−0View file
@@ -0,0 +1,169 @@
1+import type { Note, Song, TempoEvent, TimeSigEvent, Track } from '../types.ts';
2+import { newId } from '../types.ts';
3+import { GM_PROGRAMS } from './gm.ts';
4+
5+// Standard MIDI file parser for formats 0 and 1, with running status and a
6+// merged tempo map. Each (SMF track, channel) pair that carries notes
7+// becomes one editable track; this splits multi-channel format 0 files
8+// sensibly and is harmless for format 1.
9+
10+class Reader {
11+ pos = 0;
12+ constructor(readonly d: Uint8Array) {}
13+ u8(): number {
14+ if (this.pos >= this.d.length) throw new Error('unexpected end of MIDI data');
15+ return this.d[this.pos++];
16+ }
17+ u16(): number { return (this.u8() << 8) | this.u8(); }
18+ u32(): number { return (this.u16() << 16) | this.u16(); }
19+ bytes(n: number): Uint8Array {
20+ const out = this.d.subarray(this.pos, this.pos + n);
21+ if (out.length < n) throw new Error('unexpected end of MIDI data');
22+ this.pos += n;
23+ return out;
24+ }
25+ varLen(): number {
26+ let v = 0;
27+ for (let i = 0; i < 4; i++) {
28+ const b = this.u8();
29+ v = (v << 7) | (b & 0x7f);
30+ if ((b & 0x80) === 0) return v;
31+ }
32+ throw new Error('variable-length quantity too long');
33+ }
34+}
35+
36+interface RawNote { tick: number; duration: number; pitch: number; velocity: number; channel: number }
37+
38+export function parseMidi(data: Uint8Array): Song {
39+ const r = new Reader(data);
40+ const magic = String.fromCharCode(...r.bytes(4));
41+ if (magic !== 'MThd') throw new Error('not a standard MIDI file (missing MThd)');
42+ const headerLen = r.u32();
43+ const format = r.u16();
44+ const ntrks = r.u16();
45+ const division = r.u16();
46+ r.bytes(headerLen - 6);
47+ if (format === 2) throw new Error('MIDI format 2 is not supported');
48+ if (division & 0x8000) throw new Error('SMPTE time division is not supported');
49+ const ppq = division;
50+
51+ const tempo: TempoEvent[] = [];
52+ const timeSig: TimeSigEvent[] = [];
53+ const tracks: Track[] = [];
54+
55+ for (let ti = 0; ti < ntrks; ti++) {
56+ if (r.pos >= data.length) break;
57+ const chunk = String.fromCharCode(...r.bytes(4));
58+ const len = r.u32();
59+ const end = r.pos + len;
60+ if (chunk !== 'MTrk') { r.pos = end; continue; }
61+
62+ let tick = 0;
63+ let running = 0;
64+ let trackName = '';
65+ const notes: RawNote[] = [];
66+ const open = new Map<number, { tick: number; velocity: number }[]>();
67+ const programByChannel = new Map<number, number>();
68+
69+ const closeNote = (channel: number, pitch: number, atTick: number) => {
70+ const key = channel * 128 + pitch;
71+ const stack = open.get(key);
72+ if (!stack || stack.length === 0) return;
73+ const o = stack.pop()!;
74+ notes.push({ tick: o.tick, duration: Math.max(1, atTick - o.tick), pitch, velocity: o.velocity, channel });
75+ };
76+
77+ while (r.pos < end) {
78+ tick += r.varLen();
79+ let status = r.u8();
80+ if (status < 0x80) {
81+ if (running === 0) throw new Error('running status with no prior status byte');
82+ r.pos -= 1;
83+ status = running;
84+ }
85+ if (status === 0xff) {
86+ running = 0;
87+ const type = r.u8();
88+ const mlen = r.varLen();
89+ const body = r.bytes(mlen);
90+ if (type === 0x51 && mlen >= 3) {
91+ tempo.push({ tick, usPerQuarter: (body[0] << 16) | (body[1] << 8) | body[2] });
92+ } else if (type === 0x58 && mlen >= 2) {
93+ timeSig.push({ tick, numerator: body[0], denominator: 1 << body[1] });
94+ } else if (type === 0x03 && trackName === '') {
95+ trackName = new TextDecoder().decode(body).trim();
96+ } else if (type === 0x2f) {
97+ break;
98+ }
99+ } else if (status === 0xf0 || status === 0xf7) {
100+ running = 0;
101+ const slen = r.varLen();
102+ r.bytes(slen);
103+ } else {
104+ running = status;
105+ const kind = status >> 4;
106+ const channel = status & 0x0f;
107+ if (kind === 0x9) {
108+ const pitch = r.u8();
109+ const vel = r.u8();
110+ if (vel > 0) {
111+ const key = channel * 128 + pitch;
112+ if (!open.has(key)) open.set(key, []);
113+ open.get(key)!.push({ tick, velocity: vel });
114+ } else {
115+ closeNote(channel, pitch, tick);
116+ }
117+ } else if (kind === 0x8) {
118+ const pitch = r.u8();
119+ r.u8();
120+ closeNote(channel, pitch, tick);
121+ } else if (kind === 0xc) {
122+ const prog = r.u8();
123+ if (!programByChannel.has(channel)) programByChannel.set(channel, prog);
124+ } else if (kind === 0xd) {
125+ r.u8();
126+ } else {
127+ r.u8();
128+ r.u8();
129+ }
130+ }
131+ }
132+ r.pos = end;
133+
134+ // Close anything left hanging with a quarter-note duration.
135+ for (const [key, stack] of open) {
136+ const pitch = key % 128;
137+ const channel = Math.floor(key / 128);
138+ for (const o of stack) notes.push({ tick: o.tick, duration: Math.max(1, Math.round(ppq / 4)), pitch, velocity: o.velocity, channel });
139+ }
140+ if (notes.length === 0) continue;
141+
142+ const channels = [...new Set(notes.map((n) => n.channel))].sort((a, b) => a - b);
143+ for (const ch of channels) {
144+ const chNotes = notes
145+ .filter((n) => n.channel === ch)
146+ .sort((a, b) => a.tick - b.tick || a.pitch - b.pitch)
147+ .map<Note>((n) => ({ id: newId('n'), tick: n.tick, duration: n.duration, pitch: n.pitch, velocity: n.velocity }));
148+ const prog = programByChannel.get(ch);
149+ let name = trackName;
150+ if (channels.length > 1 && name !== '') name = `${name} ch${ch + 1}`;
151+ if (name === '') name = ch === 9 ? 'Drums' : prog !== undefined ? GM_PROGRAMS[prog] ?? `Track ${tracks.length + 1}` : `Track ${tracks.length + 1}`;
152+ tracks.push({
153+ id: newId('t'),
154+ name,
155+ instrumentId: ch === 9 ? 'drumkit' : 'pluck',
156+ volume: 1,
157+ mute: false,
158+ solo: false,
159+ notes: chNotes,
160+ });
161+ }
162+ }
163+
164+ tempo.sort((a, b) => a.tick - b.tick);
165+ timeSig.sort((a, b) => a.tick - b.tick);
166+ if (tempo.length === 0) tempo.push({ tick: 0, usPerQuarter: 500000 });
167+ if (timeSig.length === 0) timeSig.push({ tick: 0, numerator: 4, denominator: 4 });
168+ return { ppq, tempo, timeSig, tracks };
169+}
src/midi/time.tsadded+119−0View file
@@ -0,0 +1,119 @@
1+import type { TempoEvent, TimeSigEvent } from '../types.ts';
2+
3+// TempoMap converts between ticks and seconds under a piecewise-constant
4+// tempo, and enumerates beat/bar grid lines under the time signature map.
5+
6+interface Segment {
7+ tick: number;
8+ sec: number;
9+ secPerTick: number;
10+}
11+
12+export class TempoMap {
13+ private segs: Segment[];
14+
15+ constructor(readonly ppq: number, tempo: TempoEvent[], readonly timeSig: TimeSigEvent[] = []) {
16+ const evs = [...tempo].sort((a, b) => a.tick - b.tick);
17+ if (evs.length === 0 || evs[0].tick !== 0) evs.unshift({ tick: 0, usPerQuarter: 500000 });
18+ this.segs = [];
19+ let sec = 0;
20+ for (let i = 0; i < evs.length; i++) {
21+ const e = evs[i];
22+ if (i > 0) {
23+ const prev = this.segs[this.segs.length - 1];
24+ sec = prev.sec + (e.tick - prev.tick) * prev.secPerTick;
25+ }
26+ const secPerTick = e.usPerQuarter / 1e6 / ppq;
27+ // A later event at the same tick replaces the earlier one.
28+ if (this.segs.length > 0 && this.segs[this.segs.length - 1].tick === e.tick) {
29+ this.segs[this.segs.length - 1] = { tick: e.tick, sec, secPerTick };
30+ } else {
31+ this.segs.push({ tick: e.tick, sec, secPerTick });
32+ }
33+ }
34+ if (this.timeSig.length === 0 || this.timeSig[0].tick !== 0) {
35+ this.timeSig = [{ tick: 0, numerator: 4, denominator: 4 }, ...this.timeSig];
36+ }
37+ this.timeSig = [...this.timeSig].sort((a, b) => a.tick - b.tick);
38+ }
39+
40+ private segAtTick(tick: number): Segment {
41+ let lo = 0;
42+ let hi = this.segs.length - 1;
43+ while (lo < hi) {
44+ const mid = (lo + hi + 1) >> 1;
45+ if (this.segs[mid].tick <= tick) lo = mid;
46+ else hi = mid - 1;
47+ }
48+ return this.segs[lo];
49+ }
50+
51+ private segAtSec(sec: number): Segment {
52+ let lo = 0;
53+ let hi = this.segs.length - 1;
54+ while (lo < hi) {
55+ const mid = (lo + hi + 1) >> 1;
56+ if (this.segs[mid].sec <= sec) lo = mid;
57+ else hi = mid - 1;
58+ }
59+ return this.segs[lo];
60+ }
61+
62+ tickToSec(tick: number): number {
63+ const s = this.segAtTick(Math.max(0, tick));
64+ return s.sec + (tick - s.tick) * s.secPerTick;
65+ }
66+
67+ secToTick(sec: number): number {
68+ const s = this.segAtSec(Math.max(0, sec));
69+ return s.tick + (sec - s.sec) / s.secPerTick;
70+ }
71+
72+ bpmAt(tick: number): number {
73+ const s = this.segAtTick(Math.max(0, tick));
74+ return 60 / (s.secPerTick * this.ppq);
75+ }
76+
77+ // Bar/beat position for display, 1-based.
78+ barBeatAt(tick: number): { bar: number; beat: number } {
79+ let bar = 0;
80+ let t = 0;
81+ for (let i = 0; i < this.timeSig.length; i++) {
82+ const sig = this.timeSig[i];
83+ const next = i + 1 < this.timeSig.length ? this.timeSig[i + 1].tick : Infinity;
84+ const beatTicks = (this.ppq * 4) / sig.denominator;
85+ const barTicks = beatTicks * sig.numerator;
86+ if (tick < next) {
87+ const rel = tick - sig.tick;
88+ const barsIn = Math.floor(rel / barTicks);
89+ const beatIn = Math.floor((rel - barsIn * barTicks) / beatTicks);
90+ return { bar: bar + barsIn + 1, beat: beatIn + 1 };
91+ }
92+ bar += Math.round((next - sig.tick) / barTicks);
93+ t = next;
94+ }
95+ void t;
96+ return { bar: 1, beat: 1 };
97+ }
98+
99+ // Beat grid lines in [fromTick, toTick), marking bar starts.
100+ gridLines(fromTick: number, toTick: number): { tick: number; isBar: boolean }[] {
101+ const out: { tick: number; isBar: boolean }[] = [];
102+ for (let i = 0; i < this.timeSig.length; i++) {
103+ const sig = this.timeSig[i];
104+ const next = i + 1 < this.timeSig.length ? this.timeSig[i + 1].tick : toTick;
105+ if (next <= fromTick || sig.tick >= toTick) continue;
106+ const beatTicks = (this.ppq * 4) / sig.denominator;
107+ const start = Math.max(sig.tick, fromTick);
108+ let k = Math.floor((start - sig.tick) / beatTicks);
109+ for (;;) {
110+ const tick = sig.tick + k * beatTicks;
111+ if (tick >= Math.min(next, toTick)) break;
112+ if (tick >= fromTick) out.push({ tick, isBar: k % sig.numerator === 0 });
113+ k++;
114+ if (out.length > 20000) return out; // safety
115+ }
116+ }
117+ return out;
118+ }
119+}
src/midi/write.tsadded+101−0View file
@@ -0,0 +1,101 @@
1+import type { Song } from '../types.ts';
2+
3+// Standard MIDI file writer, format 1: one conductor track carrying the
4+// tempo and time signature maps, then one MTrk per song track. Drum-kit
5+// tracks are written on channel 10; the rest cycle through the others.
6+
7+class Writer {
8+ private bytes: number[] = [];
9+ u8(v: number) { this.bytes.push(v & 0xff); }
10+ u16(v: number) { this.u8(v >> 8); this.u8(v); }
11+ u32(v: number) { this.u16(v >>> 16); this.u16(v); }
12+ varLen(v: number) {
13+ if (v < 0) throw new Error('negative delta time');
14+ const stack: number[] = [v & 0x7f];
15+ let rest = Math.floor(v / 128);
16+ while (rest > 0) { stack.push((rest & 0x7f) | 0x80); rest = Math.floor(rest / 128); }
17+ for (let i = stack.length - 1; i >= 0; i--) this.u8(stack[i]);
18+ }
19+ raw(data: Uint8Array | number[]) { for (const b of data) this.u8(b); }
20+ toUint8Array(): Uint8Array { return new Uint8Array(this.bytes); }
21+}
22+
23+interface Ev { tick: number; order: number; data: number[] }
24+
25+function trackChunk(events: Ev[], endTick: number): Uint8Array {
26+ events.sort((a, b) => a.tick - b.tick || a.order - b.order);
27+ const w = new Writer();
28+ let last = 0;
29+ for (const e of events) {
30+ w.varLen(e.tick - last);
31+ last = e.tick;
32+ w.raw(e.data);
33+ }
34+ w.varLen(Math.max(0, endTick - last));
35+ w.raw([0xff, 0x2f, 0x00]);
36+ const body = w.toUint8Array();
37+ const out = new Writer();
38+ out.raw([0x4d, 0x54, 0x72, 0x6b]); // MTrk
39+ out.u32(body.length);
40+ out.raw(body);
41+ return out.toUint8Array();
42+}
43+
44+function nameMeta(name: string): number[] {
45+ const enc = new TextEncoder().encode(name.slice(0, 60));
46+ const out = [0xff, 0x03];
47+ // name length always fits one varlen byte after the slice
48+ out.push(enc.length);
49+ out.push(...enc);
50+ return out;
51+}
52+
53+export function writeMidi(song: Song): Uint8Array {
54+ const chunks: Uint8Array[] = [];
55+ let endTick = 0;
56+ for (const t of song.tracks) for (const n of t.notes) endTick = Math.max(endTick, n.tick + n.duration);
57+
58+ const conductor: Ev[] = [];
59+ for (const e of song.tempo) {
60+ conductor.push({ tick: e.tick, order: 0, data: [0xff, 0x51, 0x03, (e.usPerQuarter >> 16) & 0xff, (e.usPerQuarter >> 8) & 0xff, e.usPerQuarter & 0xff] });
61+ }
62+ for (const s of song.timeSig) {
63+ const dd = Math.round(Math.log2(s.denominator));
64+ conductor.push({ tick: s.tick, order: 1, data: [0xff, 0x58, 0x04, s.numerator, dd, 24, 8] });
65+ }
66+ chunks.push(trackChunk(conductor, endTick));
67+
68+ let nextCh = 0;
69+ for (const t of song.tracks) {
70+ let ch: number;
71+ if (t.instrumentId === 'drumkit') {
72+ ch = 9;
73+ } else {
74+ if (nextCh === 9) nextCh++;
75+ ch = nextCh % 16;
76+ nextCh++;
77+ }
78+ const evs: Ev[] = [{ tick: 0, order: -1, data: nameMeta(t.name) }];
79+ for (const n of t.notes) {
80+ const vel = Math.max(1, Math.min(127, Math.round(n.velocity)));
81+ const pitch = Math.max(0, Math.min(127, Math.round(n.pitch)));
82+ evs.push({ tick: n.tick + n.duration, order: 0, data: [0x80 | ch, pitch, 0] });
83+ evs.push({ tick: n.tick, order: 1, data: [0x90 | ch, pitch, vel] });
84+ }
85+ chunks.push(trackChunk(evs, endTick));
86+ }
87+
88+ const w = new Writer();
89+ w.raw([0x4d, 0x54, 0x68, 0x64]); // MThd
90+ w.u32(6);
91+ w.u16(1); // format 1
92+ w.u16(chunks.length);
93+ w.u16(song.ppq);
94+ const head = w.toUint8Array();
95+ const total = head.length + chunks.reduce((s, c) => s + c.length, 0);
96+ const out = new Uint8Array(total);
97+ out.set(head, 0);
98+ let off = head.length;
99+ for (const c of chunks) { out.set(c, off); off += c.length; }
100+ return out;
101+}
src/pianoroll/geometry.tsadded+20−0View file
@@ -0,0 +1,20 @@
1+// Layout constants and small pure helpers for the piano roll canvas.
2+
3+export const PITCH_MIN = 21; // A0
4+export const PITCH_MAX = 108; // C8
5+export const KEY_H = 12; // px per semitone row
6+export const RULER_H = 26;
7+export const KEYS_W = 56;
8+export const VEL_H = 72;
9+
10+export function isBlackKey(pitch: number): boolean {
11+ const pc = ((pitch % 12) + 12) % 12;
12+ return pc === 1 || pc === 3 || pc === 6 || pc === 8 || pc === 10;
13+}
14+
15+export function snapValue(tick: number, grid: number, mode: 'round' | 'floor' | 'ceil'): number {
16+ if (grid <= 0) return Math.round(tick);
17+ const q = tick / grid;
18+ const k = mode === 'round' ? Math.round(q) : mode === 'floor' ? Math.floor(q) : Math.ceil(q);
19+ return k * grid;
20+}
src/state/store.tsadded+377−0View file
@@ -0,0 +1,377 @@
1+import { useSyncExternalStore } from 'react';
2+import type { Song, SongMeta, SongRecord, Track } from '../types.ts';
3+import { defaultSong, newId } from '../types.ts';
4+import * as db from '../storage/db.ts';
5+import { parseMidi } from '../midi/parse.ts';
6+import { TempoMap } from '../midi/time.ts';
7+import { quantizeNotes } from '../midi/ops.ts';
8+import { player } from '../audio/player.ts';
9+import { Recorder, type CapturedNote } from '../input/recorder.ts';
10+
11+// Application state and every action that edits it. Song edits go through
12+// beginEdit/editSong/endEdit so a whole drag gesture is one undo step;
13+// mutateSong wraps the three for one-shot edits. Songs autosave to
14+// IndexedDB shortly after each committed edit.
15+
16+export interface AppState {
17+ ready: boolean;
18+ rec: SongRecord | null;
19+ songs: SongMeta[];
20+ activeTrackId: string | null;
21+ armedTrackId: string | null;
22+ selection: string[];
23+ snapBeats: number; // snap grid in quarter notes; 0 = off
24+ libraryOpen: boolean;
25+ status: string;
26+}
27+
28+const LAST_SONG_KEY = 'midiboot:lastSong';
29+const UNDO_CAP = 200;
30+
31+class Store {
32+ state: AppState = {
33+ ready: false,
34+ rec: null,
35+ songs: [],
36+ activeTrackId: null,
37+ armedTrackId: null,
38+ selection: [],
39+ snapBeats: 0.25,
40+ libraryOpen: false,
41+ status: '',
42+ };
43+ recorder = new Recorder();
44+
45+ private listeners = new Set<() => void>();
46+ private undoStack: Song[] = [];
47+ private redoStack: Song[] = [];
48+ private pendingUndo: Song | null = null;
49+ private saveTimer: ReturnType<typeof setTimeout> | null = null;
50+
51+ subscribe = (cb: () => void): (() => void) => {
52+ this.listeners.add(cb);
53+ return () => this.listeners.delete(cb);
54+ };
55+
56+ getSnapshot = (): AppState => this.state;
57+
58+ private set(patch: Partial<AppState>) {
59+ this.state = { ...this.state, ...patch };
60+ for (const cb of this.listeners) cb();
61+ }
62+
63+ song(): Song | null { return this.state.rec?.song ?? null; }
64+
65+ tempoMap(): TempoMap | null {
66+ const song = this.song();
67+ return song ? new TempoMap(song.ppq, song.tempo, song.timeSig) : null;
68+ }
69+
70+ snapTicks(): number {
71+ const song = this.song();
72+ if (!song || this.state.snapBeats <= 0) return 0;
73+ return Math.round(song.ppq * this.state.snapBeats);
74+ }
75+
76+ async init(): Promise<void> {
77+ const songs = await db.listSongs();
78+ let rec: SongRecord | null = null;
79+ try {
80+ const last = localStorage.getItem(LAST_SONG_KEY);
81+ if (last) rec = await db.getSong(last);
82+ } catch { /* localStorage unavailable */ }
83+ if (!rec && songs.length > 0) rec = await db.getSong(songs[0].id);
84+ if (!rec) {
85+ rec = this.freshRecord('Untitled song');
86+ await db.putSong(rec);
87+ }
88+ this.set({ ready: true, songs, rec, activeTrackId: rec.song.tracks[0]?.id ?? null });
89+ this.rememberLast(rec.id);
90+ }
91+
92+ private freshRecord(name: string, song?: Song): SongRecord {
93+ const now = Date.now();
94+ return { id: newId('s'), name, createdAt: now, updatedAt: now, song: song ?? defaultSong() };
95+ }
96+
97+ private rememberLast(id: string) {
98+ try { localStorage.setItem(LAST_SONG_KEY, id); } catch { /* ignore */ }
99+ }
100+
101+ private scheduleSave() {
102+ if (this.saveTimer !== null) clearTimeout(this.saveTimer);
103+ this.saveTimer = setTimeout(() => {
104+ this.saveTimer = null;
105+ void this.saveNow();
106+ }, 400);
107+ }
108+
109+ private async saveNow() {
110+ const rec = this.state.rec;
111+ if (!rec) return;
112+ await db.putSong(rec);
113+ this.set({ songs: await db.listSongs() });
114+ }
115+
116+ // --- edit protocol ---
117+
118+ beginEdit(): void {
119+ if (!this.state.rec || this.pendingUndo) return;
120+ this.pendingUndo = structuredClone(this.state.rec.song);
121+ }
122+
123+ editSong(fn: (song: Song) => void): void {
124+ const rec = this.state.rec;
125+ if (!rec) return;
126+ const song = structuredClone(rec.song);
127+ fn(song);
128+ this.set({ rec: { ...rec, song, updatedAt: Date.now() } });
129+ }
130+
131+ endEdit(): void {
132+ if (this.pendingUndo) {
133+ this.undoStack.push(this.pendingUndo);
134+ if (this.undoStack.length > UNDO_CAP) this.undoStack.shift();
135+ this.redoStack = [];
136+ this.pendingUndo = null;
137+ }
138+ this.scheduleSave();
139+ }
140+
141+ mutateSong(fn: (song: Song) => void): void {
142+ this.beginEdit();
143+ this.editSong(fn);
144+ this.endEdit();
145+ }
146+
147+ undo(): void {
148+ const rec = this.state.rec;
149+ const prev = this.undoStack.pop();
150+ if (!rec || !prev) return;
151+ this.redoStack.push(structuredClone(rec.song));
152+ this.set({ rec: { ...rec, song: prev, updatedAt: Date.now() }, selection: [] });
153+ this.scheduleSave();
154+ }
155+
156+ redo(): void {
157+ const rec = this.state.rec;
158+ const next = this.redoStack.pop();
159+ if (!rec || !next) return;
160+ this.undoStack.push(structuredClone(rec.song));
161+ this.set({ rec: { ...rec, song: next, updatedAt: Date.now() }, selection: [] });
162+ this.scheduleSave();
163+ }
164+
165+ private resetHistory() {
166+ this.undoStack = [];
167+ this.redoStack = [];
168+ this.pendingUndo = null;
169+ }
170+
171+ // --- library ---
172+
173+ setLibraryOpen(v: boolean): void { this.set({ libraryOpen: v }); }
174+
175+ async newSong(): Promise<void> {
176+ player.stop();
177+ const rec = this.freshRecord('Untitled song');
178+ await db.putSong(rec);
179+ this.resetHistory();
180+ this.set({ rec, songs: await db.listSongs(), activeTrackId: rec.song.tracks[0]?.id ?? null, armedTrackId: null, selection: [], libraryOpen: false });
181+ player.seek(0);
182+ this.rememberLast(rec.id);
183+ }
184+
185+ async openSong(id: string): Promise<void> {
186+ const rec = await db.getSong(id);
187+ if (!rec) return;
188+ player.stop();
189+ this.resetHistory();
190+ this.set({ rec, activeTrackId: rec.song.tracks[0]?.id ?? null, armedTrackId: null, selection: [], libraryOpen: false });
191+ player.seek(0);
192+ this.rememberLast(rec.id);
193+ }
194+
195+ async deleteSongById(id: string): Promise<void> {
196+ await db.deleteSong(id);
197+ const songs = await db.listSongs();
198+ this.set({ songs });
199+ if (this.state.rec?.id === id) {
200+ if (songs.length > 0) await this.openSong(songs[0].id);
201+ else await this.newSong();
202+ }
203+ }
204+
205+ renameSong(name: string): void {
206+ const rec = this.state.rec;
207+ if (!rec) return;
208+ this.set({ rec: { ...rec, name, updatedAt: Date.now() } });
209+ this.scheduleSave();
210+ }
211+
212+ async duplicateSong(id: string): Promise<void> {
213+ const src = await db.getSong(id);
214+ if (!src) return;
215+ const rec = this.freshRecord(`${src.name} copy`, structuredClone(src.song));
216+ await db.putSong(rec);
217+ this.set({ songs: await db.listSongs() });
218+ }
219+
220+ async importMidi(fileName: string, data: Uint8Array): Promise<void> {
221+ let song: Song;
222+ try {
223+ song = parseMidi(data);
224+ } catch (err) {
225+ this.set({ status: `Import failed: ${err instanceof Error ? err.message : String(err)}` });
226+ return;
227+ }
228+ if (song.tracks.length === 0) {
229+ this.set({ status: 'Import: no notes found in file' });
230+ return;
231+ }
232+ player.stop();
233+ const name = fileName.replace(/\.(mid|midi)$/i, '') || 'Imported song';
234+ const rec = this.freshRecord(name, song);
235+ await db.putSong(rec);
236+ this.resetHistory();
237+ this.set({ rec, songs: await db.listSongs(), activeTrackId: song.tracks[0].id, armedTrackId: null, selection: [], libraryOpen: false, status: `Imported ${song.tracks.length} track(s)` });
238+ player.seek(0);
239+ this.rememberLast(rec.id);
240+ }
241+
242+ // --- tracks ---
243+
244+ setActiveTrack(id: string): void { this.set({ activeTrackId: id, selection: [] }); }
245+
246+ setArmedTrack(id: string | null): void { this.set({ armedTrackId: id }); }
247+
248+ addTrack(): void {
249+ const id = newId('t');
250+ this.mutateSong((song) => {
251+ song.tracks.push({ id, name: `Track ${song.tracks.length + 1}`, instrumentId: 'pluck', volume: 1, mute: false, solo: false, notes: [] });
252+ });
253+ this.set({ activeTrackId: id });
254+ }
255+
256+ removeTrack(id: string): void {
257+ this.mutateSong((song) => {
258+ song.tracks = song.tracks.filter((t) => t.id !== id);
259+ });
260+ const song = this.song();
261+ if (this.state.activeTrackId === id) this.set({ activeTrackId: song?.tracks[0]?.id ?? null });
262+ if (this.state.armedTrackId === id) this.set({ armedTrackId: null });
263+ }
264+
265+ updateTrack(id: string, patch: Partial<Track>, undoable = true): void {
266+ const fn = (song: Song) => {
267+ const t = song.tracks.find((x) => x.id === id);
268+ if (t) Object.assign(t, patch);
269+ };
270+ if (undoable) this.mutateSong(fn);
271+ else {
272+ this.editSong(fn);
273+ this.scheduleSave();
274+ }
275+ }
276+
277+ setSelection(ids: string[]): void { this.set({ selection: ids }); }
278+
279+ setSnapBeats(v: number): void { this.set({ snapBeats: v }); }
280+
281+ deleteSelection(): void {
282+ if (this.state.selection.length === 0) return;
283+ const sel = new Set(this.state.selection);
284+ this.mutateSong((song) => {
285+ for (const t of song.tracks) t.notes = t.notes.filter((n) => !sel.has(n.id));
286+ });
287+ this.set({ selection: [] });
288+ }
289+
290+ quantize(): void {
291+ const grid = this.snapTicks();
292+ if (grid <= 0) { this.set({ status: 'Quantize: snap is off' }); return; }
293+ const sel = this.state.selection.length > 0 ? new Set(this.state.selection) : null;
294+ const activeId = this.state.activeTrackId;
295+ this.mutateSong((song) => {
296+ for (const t of song.tracks) {
297+ if (sel) t.notes = quantizeNotes(t.notes, grid, sel);
298+ else if (t.id === activeId) t.notes = quantizeNotes(t.notes, grid);
299+ }
300+ });
301+ }
302+
303+ // --- transport ---
304+
305+ async togglePlay(): Promise<void> {
306+ const song = this.song();
307+ if (!song) return;
308+ if (player.state !== 'stopped') this.stopTransport();
309+ else await player.play(song);
310+ }
311+
312+ async record(): Promise<void> {
313+ const song = this.song();
314+ if (!song) return;
315+ if (player.state !== 'stopped') this.stopTransport();
316+ const armed = this.state.armedTrackId ?? this.state.activeTrackId;
317+ if (!armed) { this.set({ status: 'Record: no track to record into' }); return; }
318+ this.set({ armedTrackId: armed });
319+ await player.play(song, { record: true, countInBars: 1 });
320+ this.recorder.start();
321+ }
322+
323+ stopTransport(): void {
324+ if (player.state === 'recording') {
325+ const endSec = Math.max(0, player.getPositionSec());
326+ const take = this.recorder.stop(endSec);
327+ player.stop();
328+ this.commitTake(take);
329+ } else {
330+ player.stop();
331+ }
332+ }
333+
334+ private commitTake(take: CapturedNote[]): void {
335+ if (take.length === 0) return;
336+ const song = this.song();
337+ const armed = this.state.armedTrackId;
338+ if (!song || !armed) return;
339+ const tm = new TempoMap(song.ppq, song.tempo, song.timeSig);
340+ const ids: string[] = [];
341+ this.mutateSong((s) => {
342+ const track = s.tracks.find((t) => t.id === armed);
343+ if (!track) return;
344+ for (const c of take) {
345+ const tick = Math.max(0, Math.round(tm.secToTick(Math.max(0, c.startSec))));
346+ const endTick = Math.round(tm.secToTick(Math.max(0, c.endSec)));
347+ const id = newId('n');
348+ ids.push(id);
349+ track.notes.push({ id, tick, duration: Math.max(Math.round(s.ppq / 16), endTick - tick), pitch: c.pitch, velocity: c.velocity });
350+ }
351+ track.notes.sort((a, b) => a.tick - b.tick || a.pitch - b.pitch);
352+ });
353+ this.set({ selection: ids, status: `Recorded ${take.length} note(s)` });
354+ }
355+
356+ seekTo(sec: number): void {
357+ if (player.state === 'recording') return;
358+ const song = this.song();
359+ if (player.state === 'playing' && song) {
360+ player.stop();
361+ player.seek(sec);
362+ void player.play(song);
363+ } else {
364+ player.seek(sec);
365+ }
366+ }
367+}
368+
369+export const store = new Store();
370+
371+export function useApp(): AppState {
372+ return useSyncExternalStore(store.subscribe, store.getSnapshot);
373+}
374+
375+export function usePlayerVersion(): number {
376+ return useSyncExternalStore(player.subscribe, () => player.version);
377+}
src/storage/db.tsadded+74−0View file
@@ -0,0 +1,74 @@
1+import type { SongMeta, SongRecord } from '../types.ts';
2+
3+// Song library in IndexedDB. One object store keyed by song id; the whole
4+// song is small JSON, so records are read and written whole.
5+
6+const DB_NAME = 'midiboot';
7+const STORE = 'songs';
8+
9+let dbPromise: Promise<IDBDatabase> | null = null;
10+
11+function openDb(): Promise<IDBDatabase> {
12+ if (dbPromise) return dbPromise;
13+ dbPromise = new Promise((resolve, reject) => {
14+ const req = indexedDB.open(DB_NAME, 1);
15+ req.onupgradeneeded = () => {
16+ const db = req.result;
17+ if (!db.objectStoreNames.contains(STORE)) db.createObjectStore(STORE, { keyPath: 'id' });
18+ };
19+ req.onsuccess = () => resolve(req.result);
20+ req.onerror = () => reject(req.error);
21+ });
22+ return dbPromise;
23+}
24+
25+function txDone(tx: IDBTransaction): Promise<void> {
26+ return new Promise((resolve, reject) => {
27+ tx.oncomplete = () => resolve();
28+ tx.onerror = () => reject(tx.error);
29+ tx.onabort = () => reject(tx.error);
30+ });
31+}
32+
33+export async function putSong(rec: SongRecord): Promise<void> {
34+ const db = await openDb();
35+ const tx = db.transaction(STORE, 'readwrite');
36+ tx.objectStore(STORE).put(rec);
37+ await txDone(tx);
38+}
39+
40+export async function getSong(id: string): Promise<SongRecord | null> {
41+ const db = await openDb();
42+ return new Promise((resolve, reject) => {
43+ const req = db.transaction(STORE, 'readonly').objectStore(STORE).get(id);
44+ req.onsuccess = () => resolve((req.result as SongRecord) ?? null);
45+ req.onerror = () => reject(req.error);
46+ });
47+}
48+
49+export async function deleteSong(id: string): Promise<void> {
50+ const db = await openDb();
51+ const tx = db.transaction(STORE, 'readwrite');
52+ tx.objectStore(STORE).delete(id);
53+ await txDone(tx);
54+}
55+
56+export async function listSongs(): Promise<SongMeta[]> {
57+ const db = await openDb();
58+ return new Promise((resolve, reject) => {
59+ const req = db.transaction(STORE, 'readonly').objectStore(STORE).getAll();
60+ req.onsuccess = () => {
61+ const recs = (req.result as SongRecord[]) ?? [];
62+ const metas = recs.map<SongMeta>((r) => ({
63+ id: r.id,
64+ name: r.name,
65+ updatedAt: r.updatedAt,
66+ trackCount: r.song.tracks.length,
67+ noteCount: r.song.tracks.reduce((s, t) => s + t.notes.length, 0),
68+ }));
69+ metas.sort((a, b) => b.updatedAt - a.updatedAt);
70+ resolve(metas);
71+ };
72+ req.onerror = () => reject(req.error);
73+ });
74+}
src/styles.cssadded+151−0View file
@@ -0,0 +1,151 @@
1+* { box-sizing: border-box; }
2+html, body, #root { height: 100%; margin: 0; }
3+body {
4+ background: #0f1115;
5+ color: #d7dae0;
6+ font: 13px/1.4 system-ui, sans-serif;
7+ overflow: hidden;
8+}
9+.app { display: flex; flex-direction: column; height: 100%; }
10+.loading { padding: 2rem; color: #888; }
11+
12+header, .transport {
13+ display: flex;
14+ align-items: center;
15+ gap: 8px;
16+ padding: 6px 10px;
17+ background: #191c22;
18+ border-bottom: 1px solid #262a33;
19+ flex: none;
20+}
21+.brand { font-weight: 700; color: #5aa9e6; letter-spacing: 0.5px; }
22+.spacer { flex: 1; }
23+
24+button {
25+ background: #262b35;
26+ color: #d7dae0;
27+ border: 1px solid #333a47;
28+ border-radius: 4px;
29+ padding: 4px 10px;
30+ cursor: pointer;
31+ font: inherit;
32+}
33+button:hover { background: #303747; }
34+button:disabled { opacity: 0.5; cursor: default; }
35+button.toggled { background: #3a5b82; border-color: #5aa9e6; }
36+button.rec { color: #ff5252; }
37+button.rec.toggled { background: #6e2424; border-color: #ff5252; }
38+button.mini { padding: 1px 6px; font-size: 11px; }
39+button.danger:hover { background: #6e2424; border-color: #ff5252; }
40+
41+input, select {
42+ background: #10131a;
43+ color: #d7dae0;
44+ border: 1px solid #333a47;
45+ border-radius: 4px;
46+ padding: 3px 6px;
47+ font: inherit;
48+}
49+input[type='number'] { width: 56px; }
50+input[type='checkbox'] { accent-color: #5aa9e6; }
51+input[type='range'] { padding: 0; flex: 1; accent-color: #5aa9e6; }
52+label { display: inline-flex; align-items: center; gap: 4px; white-space: nowrap; }
53+
54+.songname { width: 200px; }
55+.devicebar { display: flex; align-items: center; gap: 8px; }
56+.dot { width: 9px; height: 9px; border-radius: 50%; background: #555; display: inline-block; }
57+.dot.ok { background: #4cd964; }
58+.err { color: #e07a7a; }
59+
60+.transport .pos {
61+ font-variant-numeric: tabular-nums;
62+ background: #10131a;
63+ border: 1px solid #333a47;
64+ border-radius: 4px;
65+ padding: 3px 8px;
66+ min-width: 52px;
67+ text-align: center;
68+}
69+.transport .status { color: #aaa; }
70+
71+.main { display: flex; flex: 1; min-height: 0; }
72+
73+.tracklist {
74+ width: 250px;
75+ flex: none;
76+ overflow-y: auto;
77+ background: #14161c;
78+ border-right: 1px solid #262a33;
79+ padding: 6px;
80+ display: flex;
81+ flex-direction: column;
82+ gap: 6px;
83+}
84+.trackrow {
85+ background: #1b1f27;
86+ border: 1px solid #262a33;
87+ border-radius: 6px;
88+ padding: 6px;
89+ cursor: pointer;
90+ display: flex;
91+ flex-direction: column;
92+ gap: 5px;
93+}
94+.trackrow.active { border-color: #5aa9e6; background: #1e2530; }
95+.trackrow-top, .trackrow-bottom, .trackrow-vol { display: flex; align-items: center; gap: 5px; }
96+.trackrow .chip { width: 10px; height: 10px; border-radius: 3px; flex: none; }
97+.trackrow .trackname { flex: 1; min-width: 0; background: transparent; border-color: transparent; }
98+.trackrow .trackname:hover, .trackrow .trackname:focus { border-color: #333a47; background: #10131a; }
99+.trackrow select { flex: 1; min-width: 0; }
100+.trackrow .notecount { color: #777; font-size: 11px; white-space: nowrap; }
101+.addtrack { margin-top: 2px; }
102+
103+.pianoroll { flex: 1; min-width: 0; position: relative; }
104+.pianoroll canvas { position: absolute; inset: 0; display: block; touch-action: none; }
105+
106+.statusbar {
107+ flex: none;
108+ padding: 3px 10px;
109+ background: #191c22;
110+ border-top: 1px solid #262a33;
111+ color: #8a8f99;
112+ font-size: 12px;
113+ white-space: nowrap;
114+ overflow: hidden;
115+ text-overflow: ellipsis;
116+}
117+
118+.modal-backdrop {
119+ position: fixed;
120+ inset: 0;
121+ background: rgba(0, 0, 0, 0.55);
122+ display: flex;
123+ align-items: center;
124+ justify-content: center;
125+ z-index: 10;
126+}
127+.modal {
128+ background: #191c22;
129+ border: 1px solid #333a47;
130+ border-radius: 8px;
131+ width: min(560px, 92vw);
132+ max-height: 80vh;
133+ display: flex;
134+ flex-direction: column;
135+ padding: 14px;
136+ gap: 10px;
137+}
138+.modal-head { display: flex; align-items: center; gap: 8px; }
139+.modal-head h2 { margin: 0; font-size: 16px; flex: none; }
140+.songlist { overflow-y: auto; display: flex; flex-direction: column; gap: 4px; }
141+.songrow { display: flex; align-items: center; gap: 6px; }
142+.songrow.active .songname { color: #5aa9e6; }
143+.songopen {
144+ flex: 1;
145+ display: flex;
146+ flex-direction: column;
147+ align-items: flex-start;
148+ gap: 2px;
149+ text-align: left;
150+}
151+.dim { color: #777; font-size: 11px; }
src/types.tsadded+87−0View file
@@ -0,0 +1,87 @@
1+// Core song model. Time is stored in MIDI ticks against the song's ppq
2+// (pulses per quarter note); the tempo map converts to seconds.
3+
4+export interface Note {
5+ id: string;
6+ tick: number; // start, in ticks
7+ duration: number; // in ticks, >= 1
8+ pitch: number; // MIDI note number 0..127
9+ velocity: number; // 1..127
10+}
11+
12+export interface Track {
13+ id: string;
14+ name: string;
15+ instrumentId: string;
16+ volume: number; // 0..1.5, applied at mix time
17+ mute: boolean;
18+ solo: boolean;
19+ notes: Note[];
20+}
21+
22+export interface TempoEvent {
23+ tick: number;
24+ usPerQuarter: number; // microseconds per quarter note
25+}
26+
27+export interface TimeSigEvent {
28+ tick: number;
29+ numerator: number;
30+ denominator: number; // 4 = quarter gets the beat
31+}
32+
33+export interface Song {
34+ ppq: number;
35+ tempo: TempoEvent[]; // sorted by tick, first at tick 0
36+ timeSig: TimeSigEvent[]; // sorted by tick, first at tick 0
37+ tracks: Track[];
38+}
39+
40+export interface SongRecord {
41+ id: string;
42+ name: string;
43+ createdAt: number;
44+ updatedAt: number;
45+ song: Song;
46+}
47+
48+export interface SongMeta {
49+ id: string;
50+ name: string;
51+ updatedAt: number;
52+ trackCount: number;
53+ noteCount: number;
54+}
55+
56+let idCounter = 0;
57+export function newId(prefix = 'x'): string {
58+ idCounter += 1;
59+ return `${prefix}${Date.now().toString(36)}${idCounter.toString(36)}${Math.floor(Math.random() * 46656).toString(36)}`;
60+}
61+
62+export const DEFAULT_PPQ = 480;
63+
64+export function defaultSong(): Song {
65+ return {
66+ ppq: DEFAULT_PPQ,
67+ tempo: [{ tick: 0, usPerQuarter: 500000 }], // 120 bpm
68+ timeSig: [{ tick: 0, numerator: 4, denominator: 4 }],
69+ tracks: [
70+ {
71+ id: newId('t'),
72+ name: 'Track 1',
73+ instrumentId: 'pluck',
74+ volume: 1,
75+ mute: false,
76+ solo: false,
77+ notes: [],
78+ },
79+ ],
80+ };
81+}
82+
83+export function songEndTick(song: Song): number {
84+ let end = 0;
85+ for (const t of song.tracks) for (const n of t.notes) end = Math.max(end, n.tick + n.duration);
86+ return end;
87+}
tsconfig.jsonadded+18−0View file
@@ -0,0 +1,18 @@
1+{
2+ "compilerOptions": {
3+ "target": "ES2022",
4+ "module": "ESNext",
5+ "moduleResolution": "bundler",
6+ "allowImportingTsExtensions": true,
7+ "resolveJsonModule": true,
8+ "jsx": "react-jsx",
9+ "strict": true,
10+ "noUnusedLocals": true,
11+ "noUnusedParameters": true,
12+ "noEmit": true,
13+ "skipLibCheck": true,
14+ "lib": ["ES2022", "DOM", "DOM.Iterable"],
15+ "types": ["vite/client", "node"]
16+ },
17+ "include": ["src", "scripts"]
18+}
vite.config.tsadded+8−0View file
@@ -0,0 +1,8 @@
1+import { defineConfig } from 'vite';
2+import react from '@vitejs/plugin-react';
3+
4+export default defineConfig({
5+ base: './',
6+ plugins: [react()],
7+ build: { target: 'es2022' },
8+});