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 { listInstruments } from '../instruments/registry.ts';
7import { TempoMap } from '../midi/time.ts';
8import { clearRange, deleteTimeFromEvents, deleteTimeRange, extractRange, insertTime, insertTimeIntoEvents, quantizeNotes } from '../midi/ops.ts';
9import { player } from '../audio/player.ts';
10import { Recorder, type CapturedNote } from '../input/recorder.ts';
12// Application state and every action that edits it. Song edits go through
13// beginEdit/editSong/endEdit so a whole drag gesture is one undo step;
14// mutateSong wraps the three for one-shot edits. Songs autosave to
15// IndexedDB shortly after each committed edit.
17export interface AppState {
18 ready: boolean;
19 rec: SongRecord | null;
20 songs: SongMeta[];
21 activeTrackId: string | null;
22 armedTrackId: string | null;
23 selection: string[];
24 /** Time selection [start, end) in ticks, made by dragging in the ruler. */
25 rangeTicks: [number, number] | null;
26 loopEnabled: boolean;
27 snapBeats: number; // snap grid in quarter notes; 0 = off
28 libraryOpen: boolean;
29 status: string;
30}
32const LAST_SONG_KEY = 'midiboot:lastSong';
33const UNDO_CAP = 200;
35class Store {
36 state: AppState = {
37 ready: false,
38 rec: null,
39 songs: [],
40 activeTrackId: null,
41 armedTrackId: null,
42 selection: [],
43 rangeTicks: null,
44 loopEnabled: false,
45 snapBeats: 0.25,
46 libraryOpen: false,
47 status: '',
48 };
49 recorder = new Recorder();
51 private listeners = new Set<() => void>();
52 private undoStack: Song[] = [];
53 private redoStack: Song[] = [];
54 private pendingUndo: Song | null = null;
55 private saveTimer: ReturnType<typeof setTimeout> | null = null;
57 subscribe = (cb: () => void): (() => void) => {
58 this.listeners.add(cb);
59 return () => this.listeners.delete(cb);
60 };
62 getSnapshot = (): AppState => this.state;
64 private set(patch: Partial<AppState>) {
65 this.state = { ...this.state, ...patch };
66 for (const cb of this.listeners) cb();
67 }
69 song(): Song | null { return this.state.rec?.song ?? null; }
71 setStatus(msg: string): void { this.set({ status: msg }); }
73 tempoMap(): TempoMap | null {
74 const song = this.song();
75 return song ? new TempoMap(song.ppq, song.tempo, song.timeSig) : null;
76 }
78 snapTicks(): number {
79 const song = this.song();
80 if (!song || this.state.snapBeats <= 0) return 0;
81 return Math.round(song.ppq * this.state.snapBeats);
82 }
84 async init(): Promise<void> {
85 let songs: SongMeta[] = [];
86 try {
87 songs = await db.listSongs();
88 } catch (err) {
89 // IndexedDB unavailable or blocked: run in memory rather than
90 // sitting on the loading screen; edits will not persist.
91 const rec = this.freshRecord('Untitled song');
92 this.set({ ready: true, songs: [], rec, activeTrackId: rec.song.tracks[0]?.id ?? null,
93 status: `storage unavailable (${err instanceof Error ? err.message : String(err)}); changes will not be saved` });
94 return;
95 }
96 let rec: SongRecord | null = null;
97 try {
98 const last = localStorage.getItem(LAST_SONG_KEY);
99 if (last) rec = await db.getSong(last);
100 } catch { /* localStorage unavailable */ }
101 if (!rec && songs.length > 0) rec = await db.getSong(songs[0].id);
102 if (!rec) {
103 rec = this.freshRecord('Untitled song');
104 await db.putSong(rec);
105 }
106 this.set({ ready: true, songs, rec, activeTrackId: rec.song.tracks[0]?.id ?? null });
107 this.rememberLast(rec.id);
108 }
110 private freshRecord(name: string, song?: Song): SongRecord {
111 const now = Date.now();
112 return { id: newId('s'), name, createdAt: now, updatedAt: now, song: song ?? defaultSong() };
113 }
115 private rememberLast(id: string) {
116 try { localStorage.setItem(LAST_SONG_KEY, id); } catch { /* ignore */ }
117 }
119 private scheduleSave() {
120 if (this.saveTimer !== null) clearTimeout(this.saveTimer);
121 this.saveTimer = setTimeout(() => {
122 this.saveTimer = null;
123 void this.saveNow();
124 }, 400);
125 }
127 private async saveNow() {
128 const rec = this.state.rec;
129 if (!rec) return;
130 await db.putSong(rec);
131 this.set({ songs: await db.listSongs() });
132 }
134 // --- edit protocol ---
136 beginEdit(): void {
137 if (!this.state.rec || this.pendingUndo) return;
138 this.pendingUndo = structuredClone(this.state.rec.song);
139 }
141 editSong(fn: (song: Song) => void): void {
142 const rec = this.state.rec;
143 if (!rec) return;
144 const song = structuredClone(rec.song);
145 fn(song);
146 this.set({ rec: { ...rec, song, updatedAt: Date.now() } });
147 }
149 endEdit(): void {
150 if (this.pendingUndo) {
151 this.undoStack.push(this.pendingUndo);
152 if (this.undoStack.length > UNDO_CAP) this.undoStack.shift();
153 this.redoStack = [];
154 this.pendingUndo = null;
155 }
156 this.scheduleSave();
157 }
159 mutateSong(fn: (song: Song) => void): void {
160 this.beginEdit();
161 this.editSong(fn);
162 this.endEdit();
163 }
165 undo(): void {
166 const rec = this.state.rec;
167 const prev = this.undoStack.pop();
168 if (!rec || !prev) return;
169 this.redoStack.push(structuredClone(rec.song));
170 this.set({ rec: { ...rec, song: prev, updatedAt: Date.now() }, selection: [] });
171 this.scheduleSave();
172 }
174 redo(): void {
175 const rec = this.state.rec;
176 const next = this.redoStack.pop();
177 if (!rec || !next) return;
178 this.undoStack.push(structuredClone(rec.song));
179 this.set({ rec: { ...rec, song: next, updatedAt: Date.now() }, selection: [] });
180 this.scheduleSave();
181 }
183 private resetHistory() {
184 this.undoStack = [];
185 this.redoStack = [];
186 this.pendingUndo = null;
187 }
189 // --- library ---
191 setLibraryOpen(v: boolean): void { this.set({ libraryOpen: v }); }
193 async newSong(): Promise<void> {
194 player.stop();
195 const rec = this.freshRecord('Untitled song');
196 await db.putSong(rec);
197 this.resetHistory();
198 this.set({ rec, songs: await db.listSongs(), activeTrackId: rec.song.tracks[0]?.id ?? null, armedTrackId: null, selection: [], libraryOpen: false });
199 player.seek(0);
200 this.rememberLast(rec.id);
201 }
203 async openSong(id: string): Promise<void> {
204 const rec = await db.getSong(id);
205 if (!rec) return;
206 player.stop();
207 this.resetHistory();
208 this.set({ rec, activeTrackId: rec.song.tracks[0]?.id ?? null, armedTrackId: null, selection: [], libraryOpen: false });
209 player.seek(0);
210 this.rememberLast(rec.id);
211 }
213 async deleteSongById(id: string): Promise<void> {
214 await db.deleteSong(id);
215 const songs = await db.listSongs();
216 this.set({ songs });
217 if (this.state.rec?.id === id) {
218 if (songs.length > 0) await this.openSong(songs[0].id);
219 else await this.newSong();
220 }
221 }
223 renameSong(name: string): void {
224 const rec = this.state.rec;
225 if (!rec) return;
226 this.set({ rec: { ...rec, name, updatedAt: Date.now() } });
227 this.scheduleSave();
228 }
230 async duplicateSong(id: string): Promise<void> {
231 const src = await db.getSong(id);
232 if (!src) return;
233 const rec = this.freshRecord(`${src.name} copy`, structuredClone(src.song));
234 await db.putSong(rec);
235 this.set({ songs: await db.listSongs() });
236 }
238 async importMidi(fileName: string, data: Uint8Array): Promise<void> {
239 let song: Song;
240 try {
241 song = parseMidi(data, new Set(listInstruments().map((i) => i.id)));
242 } catch (err) {
243 this.set({ status: `Import failed: ${err instanceof Error ? err.message : String(err)}` });
244 return;
245 }
246 if (song.tracks.length === 0) {
247 this.set({ status: 'Import: no notes found in file' });
248 return;
249 }
250 player.stop();
251 const name = fileName.replace(/\.(mid|midi)$/i, '') || 'Imported song';
252 const rec = this.freshRecord(name, song);
253 await db.putSong(rec);
254 this.resetHistory();
255 this.set({ rec, songs: await db.listSongs(), activeTrackId: song.tracks[0].id, armedTrackId: null, selection: [], libraryOpen: false, status: `Imported ${song.tracks.length} track(s)` });
256 player.seek(0);
257 this.rememberLast(rec.id);
258 }
260 // --- tracks ---
262 setActiveTrack(id: string): void { this.set({ activeTrackId: id, selection: [] }); }
264 setArmedTrack(id: string | null): void { this.set({ armedTrackId: id }); }
266 addTrack(): void {
267 const id = newId('t');
268 this.mutateSong((song) => {
269 song.tracks.push({ id, name: `Track ${song.tracks.length + 1}`, instrumentId: 'pluck', volume: 1, mute: false, solo: false, notes: [] });
270 });
271 this.set({ activeTrackId: id });
272 }
274 removeTrack(id: string): void {
275 this.mutateSong((song) => {
276 song.tracks = song.tracks.filter((t) => t.id !== id);
277 });
278 const song = this.song();
279 if (this.state.activeTrackId === id) this.set({ activeTrackId: song?.tracks[0]?.id ?? null });
280 if (this.state.armedTrackId === id) this.set({ armedTrackId: null });
281 }
283 updateTrack(id: string, patch: Partial<Track>, undoable = true): void {
284 const fn = (song: Song) => {
285 const t = song.tracks.find((x) => x.id === id);
286 if (t) Object.assign(t, patch);
287 };
288 if (undoable) this.mutateSong(fn);
289 else {
290 this.editSong(fn);
291 this.scheduleSave();
292 }
293 }
295 setTrackSetting(id: string, key: string, value: number): void {
296 this.editSong((song) => {
297 const t = song.tracks.find((x) => x.id === id);
298 if (t) t.settings = { ...(t.settings ?? {}), [key]: value };
299 });
300 this.scheduleSave();
301 }
303 setSelection(ids: string[]): void { this.set({ selection: ids }); }
305 // --- time selection and arrangement edits ---
307 setRange(r: [number, number] | null): void {
308 this.set({ rangeTicks: r });
309 }
311 toggleLoop(): void {
312 this.set({ loopEnabled: !this.state.loopEnabled });
313 }
315 rangeSelectNotes(): void {
316 const song = this.song();
317 const r = this.state.rangeTicks;
318 if (!song || !r) return;
319 const ids = song.tracks.flatMap((t) => t.notes.filter((n) => n.tick < r[1] && n.tick + n.duration > r[0]).map((n) => n.id));
320 this.set({ selection: ids });
321 }
323 /** Delete the notes inside the time selection; time itself is kept. */
324 rangeClearNotes(): void {
325 const r = this.state.rangeTicks;
326 if (!r) return;
327 this.mutateSong((s) => {
328 for (const t of s.tracks) t.notes = clearRange(t.notes, r[0], r[1]);
329 });
330 this.set({ selection: [] });
331 }
333 /** Delete the time selection on every track and close the gap; the tempo
334 * and time-signature maps ripple with it. */
335 rangeDeleteTime(): void {
336 const r = this.state.rangeTicks;
337 if (!r) return;
338 this.mutateSong((s) => {
339 for (const t of s.tracks) t.notes = deleteTimeRange(t.notes, r[0], r[1]);
340 s.tempo = deleteTimeFromEvents(s.tempo, r[0], r[1]);
341 s.timeSig = deleteTimeFromEvents(s.timeSig, r[0], r[1]);
342 });
343 this.set({ selection: [], rangeTicks: null });
344 }
346 /** Insert that much empty time at the selection start, on every track. */
347 rangeInsertTime(): void {
348 const r = this.state.rangeTicks;
349 if (!r) return;
350 const span = r[1] - r[0];
351 this.mutateSong((s) => {
352 for (const t of s.tracks) t.notes = insertTime(t.notes, r[0], span);
353 s.tempo = insertTimeIntoEvents(s.tempo, r[0], span);
354 s.timeSig = insertTimeIntoEvents(s.timeSig, r[0], span);
355 });
356 }
358 /** Insert a copy of the time selection immediately after itself and move
359 * the selection onto the copy. */
360 rangeDuplicateTime(): void {
361 const r = this.state.rangeTicks;
362 if (!r) return;
363 const span = r[1] - r[0];
364 this.mutateSong((s) => {
365 for (const t of s.tracks) {
366 const copy = extractRange(t.notes, r[0], r[1]).map((n) => ({ ...n, tick: n.tick + span }));
367 t.notes = insertTime(t.notes, r[1], span);
368 t.notes.push(...copy);
369 t.notes.sort((a, b) => a.tick - b.tick || a.pitch - b.pitch);
370 }
371 s.tempo = insertTimeIntoEvents(s.tempo, r[1], span);
372 s.timeSig = insertTimeIntoEvents(s.timeSig, r[1], span);
373 });
374 this.set({ rangeTicks: [r[1], r[1] + span] });
375 }
377 /** Split notes under the playhead: the selected ones, or every note of
378 * the active track when nothing is selected. */
379 splitAtPlayhead(): void {
380 const song = this.song();
381 if (!song) return;
382 const tm = this.tempoMap()!;
383 const tick = Math.round(tm.secToTick(Math.max(0, player.getPositionSec())));
384 const sel = this.state.selection.length > 0 ? new Set(this.state.selection) : null;
385 const activeId = this.state.activeTrackId;
386 const ids: string[] = [];
387 this.mutateSong((s) => {
388 for (const t of s.tracks) {
389 if (!sel && t.id !== activeId) continue;
390 const next: typeof t.notes = [];
391 for (const n of t.notes) {
392 const target = sel ? sel.has(n.id) : true;
393 if (target && n.tick < tick && tick < n.tick + n.duration) {
394 const id2 = newId('n');
395 next.push({ ...n, duration: tick - n.tick });
396 next.push({ ...n, id: id2, tick, duration: n.tick + n.duration - tick });
397 ids.push(n.id, id2);
398 } else {
399 next.push(n);
400 }
401 }
402 t.notes = next;
403 }
404 });
405 if (ids.length > 0) this.set({ selection: ids, status: `Split ${ids.length / 2} note(s)` });
406 }
408 seekToEnd(): void {
409 const song = this.song();
410 if (!song) return;
411 this.seekTo(this.tempoMap()!.tickToSec(songEndTick(song)));
412 }
414 // --- track arrangement ---
416 moveTrack(id: string, dir: -1 | 1): void {
417 this.mutateSong((s) => {
418 const i = s.tracks.findIndex((t) => t.id === id);
419 const j = i + dir;
420 if (i < 0 || j < 0 || j >= s.tracks.length) return;
421 const [t] = s.tracks.splice(i, 1);
422 s.tracks.splice(j, 0, t);
423 });
424 }
426 duplicateTrack(id: string): void {
427 let dupId: string | null = null;
428 this.mutateSong((s) => {
429 const i = s.tracks.findIndex((t) => t.id === id);
430 if (i < 0) return;
431 const src = s.tracks[i];
432 dupId = newId('t');
433 s.tracks.splice(i + 1, 0, {
434 ...structuredClone(src),
435 id: dupId,
436 name: `${src.name} copy`,
437 notes: src.notes.map((n) => ({ ...n, id: newId('n') })),
438 });
439 });
440 if (dupId) this.set({ activeTrackId: dupId });
441 }
443 selectAll(): void {
444 const song = this.song();
445 if (!song) return;
446 const t = song.tracks.find((x) => x.id === this.state.activeTrackId) ?? song.tracks[0];
447 if (t) this.set({ selection: t.notes.map((n) => n.id) });
448 }
450 // --- clipboard (in-memory, note times relative to the earliest note) ---
452 private clipboard: { dTick: number; duration: number; pitch: number; velocity: number }[] = [];
454 copySelection(): number {
455 const song = this.song();
456 if (!song || this.state.selection.length === 0) return 0;
457 const sel = new Set(this.state.selection);
458 const notes = song.tracks.flatMap((t) => t.notes.filter((n) => sel.has(n.id)));
459 if (notes.length === 0) return 0;
460 const t0 = Math.min(...notes.map((n) => n.tick));
461 this.clipboard = notes.map((n) => ({ dTick: n.tick - t0, duration: n.duration, pitch: n.pitch, velocity: n.velocity }));
462 this.set({ status: `Copied ${notes.length} note(s)` });
463 return notes.length;
464 }
466 cutSelection(): void {
467 if (this.copySelection() > 0) this.deleteSelection();
468 }
470 pasteAtPlayhead(): void {
471 const song = this.song();
472 if (!song || this.clipboard.length === 0) return;
473 const tm = this.tempoMap()!;
474 let tick = Math.round(tm.secToTick(Math.max(0, player.getPositionSec())));
475 const grid = this.snapTicks();
476 if (grid > 0) tick = Math.round(tick / grid) * grid;
477 const activeId = this.state.activeTrackId;
478 const ids: string[] = [];
479 this.mutateSong((s) => {
480 const t = s.tracks.find((x) => x.id === activeId) ?? s.tracks[0];
481 if (!t) return;
482 for (const c of this.clipboard) {
483 const id = newId('n');
484 ids.push(id);
485 t.notes.push({ id, tick: Math.max(0, tick + c.dTick), duration: c.duration, pitch: c.pitch, velocity: c.velocity });
486 }
487 t.notes.sort((a, b) => a.tick - b.tick || a.pitch - b.pitch);
488 });
489 this.set({ selection: ids, status: `Pasted ${ids.length} note(s)` });
490 }
492 /** Duplicate the selection immediately after itself (span rounded up to
493 * the snap grid), the usual ctrl+D. */
494 duplicateSelection(): void {
495 const song = this.song();
496 if (!song || this.state.selection.length === 0) return;
497 const sel = new Set(this.state.selection);
498 const notes = song.tracks.flatMap((t) => t.notes.filter((n) => sel.has(n.id)));
499 if (notes.length === 0) return;
500 const t0 = Math.min(...notes.map((n) => n.tick));
501 const end = Math.max(...notes.map((n) => n.tick + n.duration));
502 let span = Math.max(1, end - t0);
503 const grid = this.snapTicks();
504 if (grid > 0) span = Math.ceil(span / grid) * grid;
505 const ids: string[] = [];
506 this.mutateSong((s) => {
507 for (const t of s.tracks) {
508 const copies = t.notes
509 .filter((n) => sel.has(n.id))
510 .map((n) => {
511 const id = newId('n');
512 ids.push(id);
513 return { ...n, id, tick: n.tick + span };
514 });
515 if (copies.length > 0) {
516 t.notes.push(...copies);
517 t.notes.sort((a, b) => a.tick - b.tick || a.pitch - b.pitch);
518 }
519 }
520 });
521 this.set({ selection: ids });
522 }
524 nudgeSelection(dTick: number, dPitch: number): void {
525 if (this.state.selection.length === 0) return;
526 const sel = new Set(this.state.selection);
527 this.mutateSong((s) => {
528 for (const t of s.tracks) {
529 for (const n of t.notes) {
530 if (!sel.has(n.id)) continue;
531 n.tick = Math.max(0, n.tick + dTick);
532 n.pitch = Math.max(0, Math.min(127, n.pitch + dPitch));
533 }
534 }
535 });
536 }
538 setSnapBeats(v: number): void { this.set({ snapBeats: v }); }
540 deleteSelection(): void {
541 if (this.state.selection.length === 0) return;
542 const sel = new Set(this.state.selection);
543 this.mutateSong((song) => {
544 for (const t of song.tracks) t.notes = t.notes.filter((n) => !sel.has(n.id));
545 });
546 this.set({ selection: [] });
547 }
549 quantize(): void {
550 const grid = this.snapTicks();
551 if (grid <= 0) { this.set({ status: 'Quantize: snap is off' }); return; }
552 const sel = this.state.selection.length > 0 ? new Set(this.state.selection) : null;
553 const activeId = this.state.activeTrackId;
554 this.mutateSong((song) => {
555 for (const t of song.tracks) {
556 if (sel) t.notes = quantizeNotes(t.notes, grid, sel);
557 else if (t.id === activeId) t.notes = quantizeNotes(t.notes, grid);
558 }
559 });
560 }
562 // --- transport ---
564 async togglePlay(): Promise<void> {
565 const song = this.song();
566 if (!song) return;
567 if (player.state !== 'stopped') this.stopTransport();
568 else {
569 try {
570 let loop: { startSec: number; endSec: number } | undefined;
571 const r = this.state.rangeTicks;
572 if (this.state.loopEnabled && r) {
573 const tm = this.tempoMap()!;
574 loop = { startSec: tm.tickToSec(r[0]), endSec: tm.tickToSec(r[1]) };
575 const pos = player.getPositionSec();
576 if (pos < loop.startSec - 1e-3 || pos >= loop.endSec - 1e-3) player.seek(loop.startSec);
577 }
578 await player.play(song, { loop });
579 } catch (err) {
580 player.stop();
581 this.set({ status: `Playback failed: ${err instanceof Error ? err.message : String(err)}` });
582 }
583 }
584 }
586 async record(): Promise<void> {
587 const song = this.song();
588 if (!song) return;
589 if (player.state !== 'stopped') this.stopTransport();
590 const armed = this.state.armedTrackId ?? this.state.activeTrackId;
591 if (!armed) { this.set({ status: 'Record: no track to record into' }); return; }
592 this.set({ armedTrackId: armed });
593 try {
594 await player.play(song, { record: true, countInBars: 1 });
595 } catch (err) {
596 player.stop();
597 this.set({ status: `Record failed: ${err instanceof Error ? err.message : String(err)}` });
598 return;
599 }
600 this.recorder.start();
601 }
603 stopTransport(): void {
604 if (player.state === 'recording') {
605 const endSec = Math.max(0, player.getPositionSec());
606 const take = this.recorder.stop(endSec);
607 player.stop();
608 this.commitTake(take);
609 } else {
610 player.stop();
611 }
612 }
614 private commitTake(take: CapturedNote[]): void {
615 if (take.length === 0) return;
616 const song = this.song();
617 const armed = this.state.armedTrackId;
618 if (!song || !armed) return;
619 const tm = new TempoMap(song.ppq, song.tempo, song.timeSig);
620 const ids: string[] = [];
621 this.mutateSong((s) => {
622 const track = s.tracks.find((t) => t.id === armed);
623 if (!track) return;
624 for (const c of take) {
625 const tick = Math.max(0, Math.round(tm.secToTick(Math.max(0, c.startSec))));
626 const endTick = Math.round(tm.secToTick(Math.max(0, c.endSec)));
627 const id = newId('n');
628 ids.push(id);
629 track.notes.push({ id, tick, duration: Math.max(Math.round(s.ppq / 16), endTick - tick), pitch: c.pitch, velocity: c.velocity });
630 }
631 track.notes.sort((a, b) => a.tick - b.tick || a.pitch - b.pitch);
632 });
633 this.set({ selection: ids, status: `Recorded ${take.length} note(s)` });
634 }
636 seekTo(sec: number): void {
637 if (player.state === 'recording') return;
638 const song = this.song();
639 if (player.state === 'playing' && song) {
640 player.stop();
641 player.seek(sec);
642 void player.play(song);
643 } else {
644 player.seek(sec);
645 }
646 }
647}
649export const store = new Store();
651export function useApp(): AppState {
652 return useSyncExternalStore(store.subscribe, store.getSnapshot);
653}
655export function usePlayerVersion(): number {
656 return useSyncExternalStore(player.subscribe, () => player.version);
657}