/ proof-of-concept / midiboot
Sign in
proof-of-concept / midiboot
midiboot / src / state / store.ts
377 lines · 11.4 KBBlameHistoryRaw
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;
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; }
65 tempoMap(): TempoMap | null {
66 const song = this.song();
67 return song ? new TempoMap(song.ppq, song.tempo, song.timeSig) : null;
68 }
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 }
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 }
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 }
97 private rememberLast(id: string) {
98 try { localStorage.setItem(LAST_SONG_KEY, id); } catch { /* ignore */ }
99 }
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 }
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 }
116 // --- edit protocol ---
118 beginEdit(): void {
119 if (!this.state.rec || this.pendingUndo) return;
120 this.pendingUndo = structuredClone(this.state.rec.song);
121 }
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 }
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 }
141 mutateSong(fn: (song: Song) => void): void {
142 this.beginEdit();
143 this.editSong(fn);
144 this.endEdit();
145 }
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 }
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 }
165 private resetHistory() {
166 this.undoStack = [];
167 this.redoStack = [];
168 this.pendingUndo = null;
169 }
171 // --- library ---
173 setLibraryOpen(v: boolean): void { this.set({ libraryOpen: v }); }
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 }
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 }
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 }
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 }
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 }
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 }
242 // --- tracks ---
244 setActiveTrack(id: string): void { this.set({ activeTrackId: id, selection: [] }); }
246 setArmedTrack(id: string | null): void { this.set({ armedTrackId: id }); }
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 }
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 }
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 }
277 setSelection(ids: string[]): void { this.set({ selection: ids }); }
279 setSnapBeats(v: number): void { this.set({ snapBeats: v }); }
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 }
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 }
303 // --- transport ---
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 }
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 }
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 }
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 }
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 }
369export const store = new Store();
371export function useApp(): AppState {
372 return useSyncExternalStore(store.subscribe, store.getSnapshot);
375export function usePlayerVersion(): number {
376 return useSyncExternalStore(player.subscribe, () => player.version);
↑↓ move↵openescclose