/ proof-of-concept / midiboot
Sign in
proof-of-concept / midiboot
midiboot / src / state / store.ts
646 lines · 20.9 KBBlameHistoryRaw
1import { useSyncExternalStore } from 'react';
2import type { Song, SongMeta, SongRecord, Track } from '../types.ts';
3import { defaultSong, newId, songEndTick } from '../types.ts';
4import * as db from '../storage/db.ts';
5import { parseMidi } from '../midi/parse.ts';
6import { TempoMap } from '../midi/time.ts';
7import { clearRange, deleteTimeFromEvents, deleteTimeRange, extractRange, insertTime, insertTimeIntoEvents, 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 /** Time selection [start, end) in ticks, made by dragging in the ruler. */
24 rangeTicks: [number, number] | null;
25 loopEnabled: boolean;
26 snapBeats: number; // snap grid in quarter notes; 0 = off
27 libraryOpen: boolean;
28 status: string;
31const LAST_SONG_KEY = 'midiboot:lastSong';
32const UNDO_CAP = 200;
34class Store {
35 state: AppState = {
36 ready: false,
37 rec: null,
38 songs: [],
39 activeTrackId: null,
40 armedTrackId: null,
41 selection: [],
42 rangeTicks: null,
43 loopEnabled: false,
44 snapBeats: 0.25,
45 libraryOpen: false,
46 status: '',
47 };
48 recorder = new Recorder();
50 private listeners = new Set<() => void>();
51 private undoStack: Song[] = [];
52 private redoStack: Song[] = [];
53 private pendingUndo: Song | null = null;
54 private saveTimer: ReturnType<typeof setTimeout> | null = null;
56 subscribe = (cb: () => void): (() => void) => {
57 this.listeners.add(cb);
58 return () => this.listeners.delete(cb);
59 };
61 getSnapshot = (): AppState => this.state;
63 private set(patch: Partial<AppState>) {
64 this.state = { ...this.state, ...patch };
65 for (const cb of this.listeners) cb();
66 }
68 song(): Song | null { return this.state.rec?.song ?? null; }
70 setStatus(msg: string): void { this.set({ status: msg }); }
72 tempoMap(): TempoMap | null {
73 const song = this.song();
74 return song ? new TempoMap(song.ppq, song.tempo, song.timeSig) : null;
75 }
77 snapTicks(): number {
78 const song = this.song();
79 if (!song || this.state.snapBeats <= 0) return 0;
80 return Math.round(song.ppq * this.state.snapBeats);
81 }
83 async init(): Promise<void> {
84 const songs = await db.listSongs();
85 let rec: SongRecord | null = null;
86 try {
87 const last = localStorage.getItem(LAST_SONG_KEY);
88 if (last) rec = await db.getSong(last);
89 } catch { /* localStorage unavailable */ }
90 if (!rec && songs.length > 0) rec = await db.getSong(songs[0].id);
91 if (!rec) {
92 rec = this.freshRecord('Untitled song');
93 await db.putSong(rec);
94 }
95 this.set({ ready: true, songs, rec, activeTrackId: rec.song.tracks[0]?.id ?? null });
96 this.rememberLast(rec.id);
97 }
99 private freshRecord(name: string, song?: Song): SongRecord {
100 const now = Date.now();
101 return { id: newId('s'), name, createdAt: now, updatedAt: now, song: song ?? defaultSong() };
102 }
104 private rememberLast(id: string) {
105 try { localStorage.setItem(LAST_SONG_KEY, id); } catch { /* ignore */ }
106 }
108 private scheduleSave() {
109 if (this.saveTimer !== null) clearTimeout(this.saveTimer);
110 this.saveTimer = setTimeout(() => {
111 this.saveTimer = null;
112 void this.saveNow();
113 }, 400);
114 }
116 private async saveNow() {
117 const rec = this.state.rec;
118 if (!rec) return;
119 await db.putSong(rec);
120 this.set({ songs: await db.listSongs() });
121 }
123 // --- edit protocol ---
125 beginEdit(): void {
126 if (!this.state.rec || this.pendingUndo) return;
127 this.pendingUndo = structuredClone(this.state.rec.song);
128 }
130 editSong(fn: (song: Song) => void): void {
131 const rec = this.state.rec;
132 if (!rec) return;
133 const song = structuredClone(rec.song);
134 fn(song);
135 this.set({ rec: { ...rec, song, updatedAt: Date.now() } });
136 }
138 endEdit(): void {
139 if (this.pendingUndo) {
140 this.undoStack.push(this.pendingUndo);
141 if (this.undoStack.length > UNDO_CAP) this.undoStack.shift();
142 this.redoStack = [];
143 this.pendingUndo = null;
144 }
145 this.scheduleSave();
146 }
148 mutateSong(fn: (song: Song) => void): void {
149 this.beginEdit();
150 this.editSong(fn);
151 this.endEdit();
152 }
154 undo(): void {
155 const rec = this.state.rec;
156 const prev = this.undoStack.pop();
157 if (!rec || !prev) return;
158 this.redoStack.push(structuredClone(rec.song));
159 this.set({ rec: { ...rec, song: prev, updatedAt: Date.now() }, selection: [] });
160 this.scheduleSave();
161 }
163 redo(): void {
164 const rec = this.state.rec;
165 const next = this.redoStack.pop();
166 if (!rec || !next) return;
167 this.undoStack.push(structuredClone(rec.song));
168 this.set({ rec: { ...rec, song: next, updatedAt: Date.now() }, selection: [] });
169 this.scheduleSave();
170 }
172 private resetHistory() {
173 this.undoStack = [];
174 this.redoStack = [];
175 this.pendingUndo = null;
176 }
178 // --- library ---
180 setLibraryOpen(v: boolean): void { this.set({ libraryOpen: v }); }
182 async newSong(): Promise<void> {
183 player.stop();
184 const rec = this.freshRecord('Untitled song');
185 await db.putSong(rec);
186 this.resetHistory();
187 this.set({ rec, songs: await db.listSongs(), activeTrackId: rec.song.tracks[0]?.id ?? null, armedTrackId: null, selection: [], libraryOpen: false });
188 player.seek(0);
189 this.rememberLast(rec.id);
190 }
192 async openSong(id: string): Promise<void> {
193 const rec = await db.getSong(id);
194 if (!rec) return;
195 player.stop();
196 this.resetHistory();
197 this.set({ rec, activeTrackId: rec.song.tracks[0]?.id ?? null, armedTrackId: null, selection: [], libraryOpen: false });
198 player.seek(0);
199 this.rememberLast(rec.id);
200 }
202 async deleteSongById(id: string): Promise<void> {
203 await db.deleteSong(id);
204 const songs = await db.listSongs();
205 this.set({ songs });
206 if (this.state.rec?.id === id) {
207 if (songs.length > 0) await this.openSong(songs[0].id);
208 else await this.newSong();
209 }
210 }
212 renameSong(name: string): void {
213 const rec = this.state.rec;
214 if (!rec) return;
215 this.set({ rec: { ...rec, name, updatedAt: Date.now() } });
216 this.scheduleSave();
217 }
219 async duplicateSong(id: string): Promise<void> {
220 const src = await db.getSong(id);
221 if (!src) return;
222 const rec = this.freshRecord(`${src.name} copy`, structuredClone(src.song));
223 await db.putSong(rec);
224 this.set({ songs: await db.listSongs() });
225 }
227 async importMidi(fileName: string, data: Uint8Array): Promise<void> {
228 let song: Song;
229 try {
230 song = parseMidi(data);
231 } catch (err) {
232 this.set({ status: `Import failed: ${err instanceof Error ? err.message : String(err)}` });
233 return;
234 }
235 if (song.tracks.length === 0) {
236 this.set({ status: 'Import: no notes found in file' });
237 return;
238 }
239 player.stop();
240 const name = fileName.replace(/\.(mid|midi)$/i, '') || 'Imported song';
241 const rec = this.freshRecord(name, song);
242 await db.putSong(rec);
243 this.resetHistory();
244 this.set({ rec, songs: await db.listSongs(), activeTrackId: song.tracks[0].id, armedTrackId: null, selection: [], libraryOpen: false, status: `Imported ${song.tracks.length} track(s)` });
245 player.seek(0);
246 this.rememberLast(rec.id);
247 }
249 // --- tracks ---
251 setActiveTrack(id: string): void { this.set({ activeTrackId: id, selection: [] }); }
253 setArmedTrack(id: string | null): void { this.set({ armedTrackId: id }); }
255 addTrack(): void {
256 const id = newId('t');
257 this.mutateSong((song) => {
258 song.tracks.push({ id, name: `Track ${song.tracks.length + 1}`, instrumentId: 'pluck', volume: 1, mute: false, solo: false, notes: [] });
259 });
260 this.set({ activeTrackId: id });
261 }
263 removeTrack(id: string): void {
264 this.mutateSong((song) => {
265 song.tracks = song.tracks.filter((t) => t.id !== id);
266 });
267 const song = this.song();
268 if (this.state.activeTrackId === id) this.set({ activeTrackId: song?.tracks[0]?.id ?? null });
269 if (this.state.armedTrackId === id) this.set({ armedTrackId: null });
270 }
272 updateTrack(id: string, patch: Partial<Track>, undoable = true): void {
273 const fn = (song: Song) => {
274 const t = song.tracks.find((x) => x.id === id);
275 if (t) Object.assign(t, patch);
276 };
277 if (undoable) this.mutateSong(fn);
278 else {
279 this.editSong(fn);
280 this.scheduleSave();
281 }
282 }
284 setTrackSetting(id: string, key: string, value: number): void {
285 this.editSong((song) => {
286 const t = song.tracks.find((x) => x.id === id);
287 if (t) t.settings = { ...(t.settings ?? {}), [key]: value };
288 });
289 this.scheduleSave();
290 }
292 setSelection(ids: string[]): void { this.set({ selection: ids }); }
294 // --- time selection and arrangement edits ---
296 setRange(r: [number, number] | null): void {
297 this.set({ rangeTicks: r });
298 }
300 toggleLoop(): void {
301 this.set({ loopEnabled: !this.state.loopEnabled });
302 }
304 rangeSelectNotes(): void {
305 const song = this.song();
306 const r = this.state.rangeTicks;
307 if (!song || !r) return;
308 const ids = song.tracks.flatMap((t) => t.notes.filter((n) => n.tick < r[1] && n.tick + n.duration > r[0]).map((n) => n.id));
309 this.set({ selection: ids });
310 }
312 /** Delete the notes inside the time selection; time itself is kept. */
313 rangeClearNotes(): void {
314 const r = this.state.rangeTicks;
315 if (!r) return;
316 this.mutateSong((s) => {
317 for (const t of s.tracks) t.notes = clearRange(t.notes, r[0], r[1]);
318 });
319 this.set({ selection: [] });
320 }
322 /** Delete the time selection on every track and close the gap; the tempo
323 * and time-signature maps ripple with it. */
324 rangeDeleteTime(): void {
325 const r = this.state.rangeTicks;
326 if (!r) return;
327 this.mutateSong((s) => {
328 for (const t of s.tracks) t.notes = deleteTimeRange(t.notes, r[0], r[1]);
329 s.tempo = deleteTimeFromEvents(s.tempo, r[0], r[1]);
330 s.timeSig = deleteTimeFromEvents(s.timeSig, r[0], r[1]);
331 });
332 this.set({ selection: [], rangeTicks: null });
333 }
335 /** Insert that much empty time at the selection start, on every track. */
336 rangeInsertTime(): void {
337 const r = this.state.rangeTicks;
338 if (!r) return;
339 const span = r[1] - r[0];
340 this.mutateSong((s) => {
341 for (const t of s.tracks) t.notes = insertTime(t.notes, r[0], span);
342 s.tempo = insertTimeIntoEvents(s.tempo, r[0], span);
343 s.timeSig = insertTimeIntoEvents(s.timeSig, r[0], span);
344 });
345 }
347 /** Insert a copy of the time selection immediately after itself and move
348 * the selection onto the copy. */
349 rangeDuplicateTime(): void {
350 const r = this.state.rangeTicks;
351 if (!r) return;
352 const span = r[1] - r[0];
353 this.mutateSong((s) => {
354 for (const t of s.tracks) {
355 const copy = extractRange(t.notes, r[0], r[1]).map((n) => ({ ...n, tick: n.tick + span }));
356 t.notes = insertTime(t.notes, r[1], span);
357 t.notes.push(...copy);
358 t.notes.sort((a, b) => a.tick - b.tick || a.pitch - b.pitch);
359 }
360 s.tempo = insertTimeIntoEvents(s.tempo, r[1], span);
361 s.timeSig = insertTimeIntoEvents(s.timeSig, r[1], span);
362 });
363 this.set({ rangeTicks: [r[1], r[1] + span] });
364 }
366 /** Split notes under the playhead: the selected ones, or every note of
367 * the active track when nothing is selected. */
368 splitAtPlayhead(): void {
369 const song = this.song();
370 if (!song) return;
371 const tm = this.tempoMap()!;
372 const tick = Math.round(tm.secToTick(Math.max(0, player.getPositionSec())));
373 const sel = this.state.selection.length > 0 ? new Set(this.state.selection) : null;
374 const activeId = this.state.activeTrackId;
375 const ids: string[] = [];
376 this.mutateSong((s) => {
377 for (const t of s.tracks) {
378 if (!sel && t.id !== activeId) continue;
379 const next: typeof t.notes = [];
380 for (const n of t.notes) {
381 const target = sel ? sel.has(n.id) : true;
382 if (target && n.tick < tick && tick < n.tick + n.duration) {
383 const id2 = newId('n');
384 next.push({ ...n, duration: tick - n.tick });
385 next.push({ ...n, id: id2, tick, duration: n.tick + n.duration - tick });
386 ids.push(n.id, id2);
387 } else {
388 next.push(n);
389 }
390 }
391 t.notes = next;
392 }
393 });
394 if (ids.length > 0) this.set({ selection: ids, status: `Split ${ids.length / 2} note(s)` });
395 }
397 seekToEnd(): void {
398 const song = this.song();
399 if (!song) return;
400 this.seekTo(this.tempoMap()!.tickToSec(songEndTick(song)));
401 }
403 // --- track arrangement ---
405 moveTrack(id: string, dir: -1 | 1): void {
406 this.mutateSong((s) => {
407 const i = s.tracks.findIndex((t) => t.id === id);
408 const j = i + dir;
409 if (i < 0 || j < 0 || j >= s.tracks.length) return;
410 const [t] = s.tracks.splice(i, 1);
411 s.tracks.splice(j, 0, t);
412 });
413 }
415 duplicateTrack(id: string): void {
416 let dupId: string | null = null;
417 this.mutateSong((s) => {
418 const i = s.tracks.findIndex((t) => t.id === id);
419 if (i < 0) return;
420 const src = s.tracks[i];
421 dupId = newId('t');
422 s.tracks.splice(i + 1, 0, {
423 ...structuredClone(src),
424 id: dupId,
425 name: `${src.name} copy`,
426 notes: src.notes.map((n) => ({ ...n, id: newId('n') })),
427 });
428 });
429 if (dupId) this.set({ activeTrackId: dupId });
430 }
432 selectAll(): void {
433 const song = this.song();
434 if (!song) return;
435 const t = song.tracks.find((x) => x.id === this.state.activeTrackId) ?? song.tracks[0];
436 if (t) this.set({ selection: t.notes.map((n) => n.id) });
437 }
439 // --- clipboard (in-memory, note times relative to the earliest note) ---
441 private clipboard: { dTick: number; duration: number; pitch: number; velocity: number }[] = [];
443 copySelection(): number {
444 const song = this.song();
445 if (!song || this.state.selection.length === 0) return 0;
446 const sel = new Set(this.state.selection);
447 const notes = song.tracks.flatMap((t) => t.notes.filter((n) => sel.has(n.id)));
448 if (notes.length === 0) return 0;
449 const t0 = Math.min(...notes.map((n) => n.tick));
450 this.clipboard = notes.map((n) => ({ dTick: n.tick - t0, duration: n.duration, pitch: n.pitch, velocity: n.velocity }));
451 this.set({ status: `Copied ${notes.length} note(s)` });
452 return notes.length;
453 }
455 cutSelection(): void {
456 if (this.copySelection() > 0) this.deleteSelection();
457 }
459 pasteAtPlayhead(): void {
460 const song = this.song();
461 if (!song || this.clipboard.length === 0) return;
462 const tm = this.tempoMap()!;
463 let tick = Math.round(tm.secToTick(Math.max(0, player.getPositionSec())));
464 const grid = this.snapTicks();
465 if (grid > 0) tick = Math.round(tick / grid) * grid;
466 const activeId = this.state.activeTrackId;
467 const ids: string[] = [];
468 this.mutateSong((s) => {
469 const t = s.tracks.find((x) => x.id === activeId) ?? s.tracks[0];
470 if (!t) return;
471 for (const c of this.clipboard) {
472 const id = newId('n');
473 ids.push(id);
474 t.notes.push({ id, tick: Math.max(0, tick + c.dTick), duration: c.duration, pitch: c.pitch, velocity: c.velocity });
475 }
476 t.notes.sort((a, b) => a.tick - b.tick || a.pitch - b.pitch);
477 });
478 this.set({ selection: ids, status: `Pasted ${ids.length} note(s)` });
479 }
481 /** Duplicate the selection immediately after itself (span rounded up to
482 * the snap grid), the usual ctrl+D. */
483 duplicateSelection(): void {
484 const song = this.song();
485 if (!song || this.state.selection.length === 0) return;
486 const sel = new Set(this.state.selection);
487 const notes = song.tracks.flatMap((t) => t.notes.filter((n) => sel.has(n.id)));
488 if (notes.length === 0) return;
489 const t0 = Math.min(...notes.map((n) => n.tick));
490 const end = Math.max(...notes.map((n) => n.tick + n.duration));
491 let span = Math.max(1, end - t0);
492 const grid = this.snapTicks();
493 if (grid > 0) span = Math.ceil(span / grid) * grid;
494 const ids: string[] = [];
495 this.mutateSong((s) => {
496 for (const t of s.tracks) {
497 const copies = t.notes
498 .filter((n) => sel.has(n.id))
499 .map((n) => {
500 const id = newId('n');
501 ids.push(id);
502 return { ...n, id, tick: n.tick + span };
503 });
504 if (copies.length > 0) {
505 t.notes.push(...copies);
506 t.notes.sort((a, b) => a.tick - b.tick || a.pitch - b.pitch);
507 }
508 }
509 });
510 this.set({ selection: ids });
511 }
513 nudgeSelection(dTick: number, dPitch: number): void {
514 if (this.state.selection.length === 0) return;
515 const sel = new Set(this.state.selection);
516 this.mutateSong((s) => {
517 for (const t of s.tracks) {
518 for (const n of t.notes) {
519 if (!sel.has(n.id)) continue;
520 n.tick = Math.max(0, n.tick + dTick);
521 n.pitch = Math.max(0, Math.min(127, n.pitch + dPitch));
522 }
523 }
524 });
525 }
527 setSnapBeats(v: number): void { this.set({ snapBeats: v }); }
529 deleteSelection(): void {
530 if (this.state.selection.length === 0) return;
531 const sel = new Set(this.state.selection);
532 this.mutateSong((song) => {
533 for (const t of song.tracks) t.notes = t.notes.filter((n) => !sel.has(n.id));
534 });
535 this.set({ selection: [] });
536 }
538 quantize(): void {
539 const grid = this.snapTicks();
540 if (grid <= 0) { this.set({ status: 'Quantize: snap is off' }); return; }
541 const sel = this.state.selection.length > 0 ? new Set(this.state.selection) : null;
542 const activeId = this.state.activeTrackId;
543 this.mutateSong((song) => {
544 for (const t of song.tracks) {
545 if (sel) t.notes = quantizeNotes(t.notes, grid, sel);
546 else if (t.id === activeId) t.notes = quantizeNotes(t.notes, grid);
547 }
548 });
549 }
551 // --- transport ---
553 async togglePlay(): Promise<void> {
554 const song = this.song();
555 if (!song) return;
556 if (player.state !== 'stopped') this.stopTransport();
557 else {
558 try {
559 let loop: { startSec: number; endSec: number } | undefined;
560 const r = this.state.rangeTicks;
561 if (this.state.loopEnabled && r) {
562 const tm = this.tempoMap()!;
563 loop = { startSec: tm.tickToSec(r[0]), endSec: tm.tickToSec(r[1]) };
564 const pos = player.getPositionSec();
565 if (pos < loop.startSec - 1e-3 || pos >= loop.endSec - 1e-3) player.seek(loop.startSec);
566 }
567 await player.play(song, { loop });
568 } catch (err) {
569 player.stop();
570 this.set({ status: `Playback failed: ${err instanceof Error ? err.message : String(err)}` });
571 }
572 }
573 }
575 async record(): Promise<void> {
576 const song = this.song();
577 if (!song) return;
578 if (player.state !== 'stopped') this.stopTransport();
579 const armed = this.state.armedTrackId ?? this.state.activeTrackId;
580 if (!armed) { this.set({ status: 'Record: no track to record into' }); return; }
581 this.set({ armedTrackId: armed });
582 try {
583 await player.play(song, { record: true, countInBars: 1 });
584 } catch (err) {
585 player.stop();
586 this.set({ status: `Record failed: ${err instanceof Error ? err.message : String(err)}` });
587 return;
588 }
589 this.recorder.start();
590 }
592 stopTransport(): void {
593 if (player.state === 'recording') {
594 const endSec = Math.max(0, player.getPositionSec());
595 const take = this.recorder.stop(endSec);
596 player.stop();
597 this.commitTake(take);
598 } else {
599 player.stop();
600 }
601 }
603 private commitTake(take: CapturedNote[]): void {
604 if (take.length === 0) return;
605 const song = this.song();
606 const armed = this.state.armedTrackId;
607 if (!song || !armed) return;
608 const tm = new TempoMap(song.ppq, song.tempo, song.timeSig);
609 const ids: string[] = [];
610 this.mutateSong((s) => {
611 const track = s.tracks.find((t) => t.id === armed);
612 if (!track) return;
613 for (const c of take) {
614 const tick = Math.max(0, Math.round(tm.secToTick(Math.max(0, c.startSec))));
615 const endTick = Math.round(tm.secToTick(Math.max(0, c.endSec)));
616 const id = newId('n');
617 ids.push(id);
618 track.notes.push({ id, tick, duration: Math.max(Math.round(s.ppq / 16), endTick - tick), pitch: c.pitch, velocity: c.velocity });
619 }
620 track.notes.sort((a, b) => a.tick - b.tick || a.pitch - b.pitch);
621 });
622 this.set({ selection: ids, status: `Recorded ${take.length} note(s)` });
623 }
625 seekTo(sec: number): void {
626 if (player.state === 'recording') return;
627 const song = this.song();
628 if (player.state === 'playing' && song) {
629 player.stop();
630 player.seek(sec);
631 void player.play(song);
632 } else {
633 player.seek(sec);
634 }
635 }
638export const store = new Store();
640export function useApp(): AppState {
641 return useSyncExternalStore(store.subscribe, store.getSnapshot);
644export function usePlayerVersion(): number {
645 return useSyncExternalStore(player.subscribe, () => player.version);
↑↓ move↵openescclose