0ad1ec1midiboot: in-browser MIDI workstation with pluggable instrumentsJeremy Magland 1import { useSyncExternalStore } from 'react';
2import type { Song, SongMeta, SongRecord, Track } from '../types.ts';
3import { defaultSong, newId } from '../types.ts';
4import * as db from '../storage/db.ts';
5import { parseMidi } from '../midi/parse.ts';
6import { TempoMap } from '../midi/time.ts';
7import { quantizeNotes } from '../midi/ops.ts';
8import { player } from '../audio/player.ts';
9import { Recorder, type CapturedNote } from '../input/recorder.ts';
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.
16export 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}
28const LAST_SONG_KEY = 'midiboot:lastSong';
29const UNDO_CAP = 200;
31class 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();
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;
51 subscribe = (cb: () => void): (() => void) => {
52 this.listeners.add(cb);
53 return () => this.listeners.delete(cb);
54 };
56 getSnapshot = (): AppState => this.state;
58 private set(patch: Partial<AppState>) {
59 this.state = { ...this.state, ...patch };
60 for (const cb of this.listeners) cb();
61 }
63 song(): Song | null { return this.state.rec?.song ?? null; }
8ad9830Placeholder instrawave adapter: drum, dulcimer, chime as instrumentsJeremy Magland 65 setStatus(msg: string): void { this.set({ status: msg }); }
0ad1ec1midiboot: in-browser MIDI workstation with pluggable instrumentsJeremy Magland 67 tempoMap(): TempoMap | null {
68 const song = this.song();
69 return song ? new TempoMap(song.ppq, song.tempo, song.timeSig) : null;
70 }
72 snapTicks(): number {
73 const song = this.song();
74 if (!song || this.state.snapBeats <= 0) return 0;
75 return Math.round(song.ppq * this.state.snapBeats);
76 }
78 async init(): Promise<void> {
79 const songs = await db.listSongs();
80 let rec: SongRecord | null = null;
81 try {
82 const last = localStorage.getItem(LAST_SONG_KEY);
83 if (last) rec = await db.getSong(last);
84 } catch { /* localStorage unavailable */ }
85 if (!rec && songs.length > 0) rec = await db.getSong(songs[0].id);
86 if (!rec) {
87 rec = this.freshRecord('Untitled song');
88 await db.putSong(rec);
89 }
90 this.set({ ready: true, songs, rec, activeTrackId: rec.song.tracks[0]?.id ?? null });
91 this.rememberLast(rec.id);
92 }
94 private freshRecord(name: string, song?: Song): SongRecord {
95 const now = Date.now();
96 return { id: newId('s'), name, createdAt: now, updatedAt: now, song: song ?? defaultSong() };
97 }
99 private rememberLast(id: string) {
100 try { localStorage.setItem(LAST_SONG_KEY, id); } catch { /* ignore */ }
101 }
103 private scheduleSave() {
104 if (this.saveTimer !== null) clearTimeout(this.saveTimer);
105 this.saveTimer = setTimeout(() => {
106 this.saveTimer = null;
107 void this.saveNow();
108 }, 400);
109 }
111 private async saveNow() {
112 const rec = this.state.rec;
113 if (!rec) return;
114 await db.putSong(rec);
115 this.set({ songs: await db.listSongs() });
116 }
118 // --- edit protocol ---
120 beginEdit(): void {
121 if (!this.state.rec || this.pendingUndo) return;
122 this.pendingUndo = structuredClone(this.state.rec.song);
123 }
125 editSong(fn: (song: Song) => void): void {
126 const rec = this.state.rec;
127 if (!rec) return;
128 const song = structuredClone(rec.song);
129 fn(song);
130 this.set({ rec: { ...rec, song, updatedAt: Date.now() } });
131 }
133 endEdit(): void {
134 if (this.pendingUndo) {
135 this.undoStack.push(this.pendingUndo);
136 if (this.undoStack.length > UNDO_CAP) this.undoStack.shift();
137 this.redoStack = [];
138 this.pendingUndo = null;
139 }
140 this.scheduleSave();
141 }
143 mutateSong(fn: (song: Song) => void): void {
144 this.beginEdit();
145 this.editSong(fn);
146 this.endEdit();
147 }
149 undo(): void {
150 const rec = this.state.rec;
151 const prev = this.undoStack.pop();
152 if (!rec || !prev) return;
153 this.redoStack.push(structuredClone(rec.song));
154 this.set({ rec: { ...rec, song: prev, updatedAt: Date.now() }, selection: [] });
155 this.scheduleSave();
156 }
158 redo(): void {
159 const rec = this.state.rec;
160 const next = this.redoStack.pop();
161 if (!rec || !next) return;
162 this.undoStack.push(structuredClone(rec.song));
163 this.set({ rec: { ...rec, song: next, updatedAt: Date.now() }, selection: [] });
164 this.scheduleSave();
165 }
167 private resetHistory() {
168 this.undoStack = [];
169 this.redoStack = [];
170 this.pendingUndo = null;
171 }
173 // --- library ---
175 setLibraryOpen(v: boolean): void { this.set({ libraryOpen: v }); }
177 async newSong(): Promise<void> {
178 player.stop();
179 const rec = this.freshRecord('Untitled song');
180 await db.putSong(rec);
181 this.resetHistory();
182 this.set({ rec, songs: await db.listSongs(), activeTrackId: rec.song.tracks[0]?.id ?? null, armedTrackId: null, selection: [], libraryOpen: false });
183 player.seek(0);
184 this.rememberLast(rec.id);
185 }
187 async openSong(id: string): Promise<void> {
188 const rec = await db.getSong(id);
189 if (!rec) return;
190 player.stop();
191 this.resetHistory();
192 this.set({ rec, activeTrackId: rec.song.tracks[0]?.id ?? null, armedTrackId: null, selection: [], libraryOpen: false });
193 player.seek(0);
194 this.rememberLast(rec.id);
195 }
197 async deleteSongById(id: string): Promise<void> {
198 await db.deleteSong(id);
199 const songs = await db.listSongs();
200 this.set({ songs });
201 if (this.state.rec?.id === id) {
202 if (songs.length > 0) await this.openSong(songs[0].id);
203 else await this.newSong();
204 }
205 }
207 renameSong(name: string): void {
208 const rec = this.state.rec;
209 if (!rec) return;
210 this.set({ rec: { ...rec, name, updatedAt: Date.now() } });
211 this.scheduleSave();
212 }
214 async duplicateSong(id: string): Promise<void> {
215 const src = await db.getSong(id);
216 if (!src) return;
217 const rec = this.freshRecord(`${src.name} copy`, structuredClone(src.song));
218 await db.putSong(rec);
219 this.set({ songs: await db.listSongs() });
220 }
222 async importMidi(fileName: string, data: Uint8Array): Promise<void> {
223 let song: Song;
224 try {
225 song = parseMidi(data);
226 } catch (err) {
227 this.set({ status: `Import failed: ${err instanceof Error ? err.message : String(err)}` });
228 return;
229 }
230 if (song.tracks.length === 0) {
231 this.set({ status: 'Import: no notes found in file' });
232 return;
233 }
234 player.stop();
235 const name = fileName.replace(/\.(mid|midi)$/i, '') || 'Imported song';
236 const rec = this.freshRecord(name, song);
237 await db.putSong(rec);
238 this.resetHistory();
239 this.set({ rec, songs: await db.listSongs(), activeTrackId: song.tracks[0].id, armedTrackId: null, selection: [], libraryOpen: false, status: `Imported ${song.tracks.length} track(s)` });
240 player.seek(0);
241 this.rememberLast(rec.id);
242 }
244 // --- tracks ---
246 setActiveTrack(id: string): void { this.set({ activeTrackId: id, selection: [] }); }
248 setArmedTrack(id: string | null): void { this.set({ armedTrackId: id }); }
250 addTrack(): void {
251 const id = newId('t');
252 this.mutateSong((song) => {
253 song.tracks.push({ id, name: `Track ${song.tracks.length + 1}`, instrumentId: 'pluck', volume: 1, mute: false, solo: false, notes: [] });
254 });
255 this.set({ activeTrackId: id });
256 }
258 removeTrack(id: string): void {
259 this.mutateSong((song) => {
260 song.tracks = song.tracks.filter((t) => t.id !== id);
261 });
262 const song = this.song();
263 if (this.state.activeTrackId === id) this.set({ activeTrackId: song?.tracks[0]?.id ?? null });
264 if (this.state.armedTrackId === id) this.set({ armedTrackId: null });
265 }
267 updateTrack(id: string, patch: Partial<Track>, undoable = true): void {
268 const fn = (song: Song) => {
269 const t = song.tracks.find((x) => x.id === id);
270 if (t) Object.assign(t, patch);
271 };
272 if (undoable) this.mutateSong(fn);
273 else {
274 this.editSong(fn);
275 this.scheduleSave();
276 }
277 }
78851f6Replace instrawave with simulated instruments driven by manifestsJeremy Magland 279 setTrackSetting(id: string, key: string, value: number): void {
280 this.editSong((song) => {
281 const t = song.tracks.find((x) => x.id === id);
282 if (t) t.settings = { ...(t.settings ?? {}), [key]: value };
283 });
284 this.scheduleSave();
285 }
0ad1ec1midiboot: in-browser MIDI workstation with pluggable instrumentsJeremy Magland 287 setSelection(ids: string[]): void { this.set({ selection: ids }); }
f73dbfaPiano roll: DAW-standard gestures and selection operationsJeremy Magland 289 selectAll(): void {
290 const song = this.song();
291 if (!song) return;
292 const t = song.tracks.find((x) => x.id === this.state.activeTrackId) ?? song.tracks[0];
293 if (t) this.set({ selection: t.notes.map((n) => n.id) });
294 }
296 // --- clipboard (in-memory, note times relative to the earliest note) ---
298 private clipboard: { dTick: number; duration: number; pitch: number; velocity: number }[] = [];
300 copySelection(): number {
301 const song = this.song();
302 if (!song || this.state.selection.length === 0) return 0;
303 const sel = new Set(this.state.selection);
304 const notes = song.tracks.flatMap((t) => t.notes.filter((n) => sel.has(n.id)));
305 if (notes.length === 0) return 0;
306 const t0 = Math.min(...notes.map((n) => n.tick));
307 this.clipboard = notes.map((n) => ({ dTick: n.tick - t0, duration: n.duration, pitch: n.pitch, velocity: n.velocity }));
308 this.set({ status: `Copied ${notes.length} note(s)` });
309 return notes.length;
310 }
312 cutSelection(): void {
313 if (this.copySelection() > 0) this.deleteSelection();
314 }
316 pasteAtPlayhead(): void {
317 const song = this.song();
318 if (!song || this.clipboard.length === 0) return;
319 const tm = this.tempoMap()!;
320 let tick = Math.round(tm.secToTick(Math.max(0, player.getPositionSec())));
321 const grid = this.snapTicks();
322 if (grid > 0) tick = Math.round(tick / grid) * grid;
323 const activeId = this.state.activeTrackId;
324 const ids: string[] = [];
325 this.mutateSong((s) => {
326 const t = s.tracks.find((x) => x.id === activeId) ?? s.tracks[0];
327 if (!t) return;
328 for (const c of this.clipboard) {
329 const id = newId('n');
330 ids.push(id);
331 t.notes.push({ id, tick: Math.max(0, tick + c.dTick), duration: c.duration, pitch: c.pitch, velocity: c.velocity });
332 }
333 t.notes.sort((a, b) => a.tick - b.tick || a.pitch - b.pitch);
334 });
335 this.set({ selection: ids, status: `Pasted ${ids.length} note(s)` });
336 }
338 /** Duplicate the selection immediately after itself (span rounded up to
339 * the snap grid), the usual ctrl+D. */
340 duplicateSelection(): void {
341 const song = this.song();
342 if (!song || this.state.selection.length === 0) return;
343 const sel = new Set(this.state.selection);
344 const notes = song.tracks.flatMap((t) => t.notes.filter((n) => sel.has(n.id)));
345 if (notes.length === 0) return;
346 const t0 = Math.min(...notes.map((n) => n.tick));
347 const end = Math.max(...notes.map((n) => n.tick + n.duration));
348 let span = Math.max(1, end - t0);
349 const grid = this.snapTicks();
350 if (grid > 0) span = Math.ceil(span / grid) * grid;
351 const ids: string[] = [];
352 this.mutateSong((s) => {
353 for (const t of s.tracks) {
354 const copies = t.notes
355 .filter((n) => sel.has(n.id))
356 .map((n) => {
357 const id = newId('n');
358 ids.push(id);
359 return { ...n, id, tick: n.tick + span };
360 });
361 if (copies.length > 0) {
362 t.notes.push(...copies);
363 t.notes.sort((a, b) => a.tick - b.tick || a.pitch - b.pitch);
364 }
365 }
366 });
367 this.set({ selection: ids });
368 }
370 nudgeSelection(dTick: number, dPitch: number): void {
371 if (this.state.selection.length === 0) return;
372 const sel = new Set(this.state.selection);
373 this.mutateSong((s) => {
374 for (const t of s.tracks) {
375 for (const n of t.notes) {
376 if (!sel.has(n.id)) continue;
377 n.tick = Math.max(0, n.tick + dTick);
378 n.pitch = Math.max(0, Math.min(127, n.pitch + dPitch));
379 }
380 }
381 });
382 }
0ad1ec1midiboot: in-browser MIDI workstation with pluggable instrumentsJeremy Magland 384 setSnapBeats(v: number): void { this.set({ snapBeats: v }); }
386 deleteSelection(): void {
387 if (this.state.selection.length === 0) return;
388 const sel = new Set(this.state.selection);
389 this.mutateSong((song) => {
390 for (const t of song.tracks) t.notes = t.notes.filter((n) => !sel.has(n.id));
391 });
392 this.set({ selection: [] });
393 }
395 quantize(): void {
396 const grid = this.snapTicks();
397 if (grid <= 0) { this.set({ status: 'Quantize: snap is off' }); return; }
398 const sel = this.state.selection.length > 0 ? new Set(this.state.selection) : null;
399 const activeId = this.state.activeTrackId;
400 this.mutateSong((song) => {
401 for (const t of song.tracks) {
402 if (sel) t.notes = quantizeNotes(t.notes, grid, sel);
403 else if (t.id === activeId) t.notes = quantizeNotes(t.notes, grid);
404 }
405 });
406 }
408 // --- transport ---
410 async togglePlay(): Promise<void> {
411 const song = this.song();
412 if (!song) return;
413 if (player.state !== 'stopped') this.stopTransport();
8ad9830Placeholder instrawave adapter: drum, dulcimer, chime as instrumentsJeremy Magland 414 else {
415 try {
416 await player.play(song);
417 } catch (err) {
418 player.stop();
419 this.set({ status: `Playback failed: ${err instanceof Error ? err.message : String(err)}` });
420 }
421 }
424 async record(): Promise<void> {
425 const song = this.song();
426 if (!song) return;
427 if (player.state !== 'stopped') this.stopTransport();
428 const armed = this.state.armedTrackId ?? this.state.activeTrackId;
429 if (!armed) { this.set({ status: 'Record: no track to record into' }); return; }
430 this.set({ armedTrackId: armed });
432 await player.play(song, { record: true, countInBars: 1 });
433 } catch (err) {
434 player.stop();
435 this.set({ status: `Record failed: ${err instanceof Error ? err.message : String(err)}` });
436 return;
437 }
0ad1ec1midiboot: in-browser MIDI workstation with pluggable instrumentsJeremy Magland 438 this.recorder.start();
439 }
441 stopTransport(): void {
442 if (player.state === 'recording') {
443 const endSec = Math.max(0, player.getPositionSec());
444 const take = this.recorder.stop(endSec);
445 player.stop();
446 this.commitTake(take);
447 } else {
448 player.stop();
449 }
450 }
452 private commitTake(take: CapturedNote[]): void {
453 if (take.length === 0) return;
454 const song = this.song();
455 const armed = this.state.armedTrackId;
456 if (!song || !armed) return;
457 const tm = new TempoMap(song.ppq, song.tempo, song.timeSig);
458 const ids: string[] = [];
459 this.mutateSong((s) => {
460 const track = s.tracks.find((t) => t.id === armed);
461 if (!track) return;
462 for (const c of take) {
463 const tick = Math.max(0, Math.round(tm.secToTick(Math.max(0, c.startSec))));
464 const endTick = Math.round(tm.secToTick(Math.max(0, c.endSec)));
465 const id = newId('n');
466 ids.push(id);
467 track.notes.push({ id, tick, duration: Math.max(Math.round(s.ppq / 16), endTick - tick), pitch: c.pitch, velocity: c.velocity });
468 }
469 track.notes.sort((a, b) => a.tick - b.tick || a.pitch - b.pitch);
470 });
471 this.set({ selection: ids, status: `Recorded ${take.length} note(s)` });
472 }
474 seekTo(sec: number): void {
475 if (player.state === 'recording') return;
476 const song = this.song();
477 if (player.state === 'playing' && song) {
478 player.stop();
479 player.seek(sec);
480 void player.play(song);
481 } else {
482 player.seek(sec);
483 }
484 }
485}
487export const store = new Store();
489export function useApp(): AppState {
490 return useSyncExternalStore(store.subscribe, store.getSnapshot);
491}
493export function usePlayerVersion(): number {
494 return useSyncExternalStore(player.subscribe, () => player.version);
495}