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;
29}
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 let songs: SongMeta[] = [];
85 try {
86 songs = await db.listSongs();
87 } catch (err) {
88 // IndexedDB unavailable or blocked: run in memory rather than
89 // sitting on the loading screen; edits will not persist.
90 const rec = this.freshRecord('Untitled song');
91 this.set({ ready: true, songs: [], rec, activeTrackId: rec.song.tracks[0]?.id ?? null,
92 status: `storage unavailable (${err instanceof Error ? err.message : String(err)}); changes will not be saved` });
93 return;
94 }
95 let rec: SongRecord | null = null;
96 try {
97 const last = localStorage.getItem(LAST_SONG_KEY);
98 if (last) rec = await db.getSong(last);
99 } catch { /* localStorage unavailable */ }
100 if (!rec && songs.length > 0) rec = await db.getSong(songs[0].id);
101 if (!rec) {
102 rec = this.freshRecord('Untitled song');
103 await db.putSong(rec);
104 }
105 this.set({ ready: true, songs, rec, activeTrackId: rec.song.tracks[0]?.id ?? null });
106 this.rememberLast(rec.id);
107 }
109 private freshRecord(name: string, song?: Song): SongRecord {
110 const now = Date.now();
111 return { id: newId('s'), name, createdAt: now, updatedAt: now, song: song ?? defaultSong() };
112 }
114 private rememberLast(id: string) {
115 try { localStorage.setItem(LAST_SONG_KEY, id); } catch { /* ignore */ }
116 }
118 private scheduleSave() {
119 if (this.saveTimer !== null) clearTimeout(this.saveTimer);
120 this.saveTimer = setTimeout(() => {
121 this.saveTimer = null;
122 void this.saveNow();
123 }, 400);
124 }
126 private async saveNow() {
127 const rec = this.state.rec;
128 if (!rec) return;
129 await db.putSong(rec);
130 this.set({ songs: await db.listSongs() });
131 }
133 // --- edit protocol ---
135 beginEdit(): void {
136 if (!this.state.rec || this.pendingUndo) return;
137 this.pendingUndo = structuredClone(this.state.rec.song);
138 }
140 editSong(fn: (song: Song) => void): void {
141 const rec = this.state.rec;
142 if (!rec) return;
143 const song = structuredClone(rec.song);
144 fn(song);
145 this.set({ rec: { ...rec, song, updatedAt: Date.now() } });
146 }
148 endEdit(): void {
149 if (this.pendingUndo) {
150 this.undoStack.push(this.pendingUndo);
151 if (this.undoStack.length > UNDO_CAP) this.undoStack.shift();
152 this.redoStack = [];
153 this.pendingUndo = null;
154 }
155 this.scheduleSave();
156 }
158 mutateSong(fn: (song: Song) => void): void {
159 this.beginEdit();
160 this.editSong(fn);
161 this.endEdit();
162 }
164 undo(): void {
165 const rec = this.state.rec;
166 const prev = this.undoStack.pop();
167 if (!rec || !prev) return;
168 this.redoStack.push(structuredClone(rec.song));
169 this.set({ rec: { ...rec, song: prev, updatedAt: Date.now() }, selection: [] });
170 this.scheduleSave();
171 }
173 redo(): void {
174 const rec = this.state.rec;
175 const next = this.redoStack.pop();
176 if (!rec || !next) return;
177 this.undoStack.push(structuredClone(rec.song));
178 this.set({ rec: { ...rec, song: next, updatedAt: Date.now() }, selection: [] });
179 this.scheduleSave();
180 }
182 private resetHistory() {
183 this.undoStack = [];
184 this.redoStack = [];
185 this.pendingUndo = null;
186 }
188 // --- library ---
190 setLibraryOpen(v: boolean): void { this.set({ libraryOpen: v }); }
192 async newSong(): Promise<void> {
193 player.stop();
194 const rec = this.freshRecord('Untitled song');
195 await db.putSong(rec);
196 this.resetHistory();
197 this.set({ rec, songs: await db.listSongs(), activeTrackId: rec.song.tracks[0]?.id ?? null, armedTrackId: null, selection: [], libraryOpen: false });
198 player.seek(0);
199 this.rememberLast(rec.id);
200 }
202 async openSong(id: string): Promise<void> {
203 const rec = await db.getSong(id);
204 if (!rec) return;
205 player.stop();
206 this.resetHistory();
207 this.set({ rec, activeTrackId: rec.song.tracks[0]?.id ?? null, armedTrackId: null, selection: [], libraryOpen: false });
208 player.seek(0);
209 this.rememberLast(rec.id);
210 }
212 async deleteSongById(id: string): Promise<void> {
213 await db.deleteSong(id);
214 const songs = await db.listSongs();
215 this.set({ songs });
216 if (this.state.rec?.id === id) {
217 if (songs.length > 0) await this.openSong(songs[0].id);
218 else await this.newSong();
219 }
220 }
222 renameSong(name: string): void {
223 const rec = this.state.rec;
224 if (!rec) return;
225 this.set({ rec: { ...rec, name, updatedAt: Date.now() } });
226 this.scheduleSave();
227 }
229 async duplicateSong(id: string): Promise<void> {
230 const src = await db.getSong(id);
231 if (!src) return;
232 const rec = this.freshRecord(`${src.name} copy`, structuredClone(src.song));
233 await db.putSong(rec);
234 this.set({ songs: await db.listSongs() });
235 }
237 async importMidi(fileName: string, data: Uint8Array): Promise<void> {
238 let song: Song;
239 try {
240 song = parseMidi(data);
241 } catch (err) {
242 this.set({ status: `Import failed: ${err instanceof Error ? err.message : String(err)}` });
243 return;
244 }
245 if (song.tracks.length === 0) {
246 this.set({ status: 'Import: no notes found in file' });
247 return;
248 }
249 player.stop();
250 const name = fileName.replace(/\.(mid|midi)$/i, '') || 'Imported song';
251 const rec = this.freshRecord(name, song);
252 await db.putSong(rec);
253 this.resetHistory();
254 this.set({ rec, songs: await db.listSongs(), activeTrackId: song.tracks[0].id, armedTrackId: null, selection: [], libraryOpen: false, status: `Imported ${song.tracks.length} track(s)` });
255 player.seek(0);
256 this.rememberLast(rec.id);
257 }
259 // --- tracks ---
261 setActiveTrack(id: string): void { this.set({ activeTrackId: id, selection: [] }); }
263 setArmedTrack(id: string | null): void { this.set({ armedTrackId: id }); }
265 addTrack(): void {
266 const id = newId('t');
267 this.mutateSong((song) => {
268 song.tracks.push({ id, name: `Track ${song.tracks.length + 1}`, instrumentId: 'pluck', volume: 1, mute: false, solo: false, notes: [] });
269 });
270 this.set({ activeTrackId: id });
271 }
273 removeTrack(id: string): void {
274 this.mutateSong((song) => {
275 song.tracks = song.tracks.filter((t) => t.id !== id);
276 });
277 const song = this.song();
278 if (this.state.activeTrackId === id) this.set({ activeTrackId: song?.tracks[0]?.id ?? null });
279 if (this.state.armedTrackId === id) this.set({ armedTrackId: null });
280 }
282 updateTrack(id: string, patch: Partial<Track>, undoable = true): void {
283 const fn = (song: Song) => {
284 const t = song.tracks.find((x) => x.id === id);
285 if (t) Object.assign(t, patch);
286 };
287 if (undoable) this.mutateSong(fn);
288 else {
289 this.editSong(fn);
290 this.scheduleSave();
291 }
292 }
294 setTrackSetting(id: string, key: string, value: number): void {
295 this.editSong((song) => {
296 const t = song.tracks.find((x) => x.id === id);
297 if (t) t.settings = { ...(t.settings ?? {}), [key]: value };
298 });
299 this.scheduleSave();
300 }
302 setSelection(ids: string[]): void { this.set({ selection: ids }); }
304 // --- time selection and arrangement edits ---
306 setRange(r: [number, number] | null): void {
307 this.set({ rangeTicks: r });
308 }
310 toggleLoop(): void {
311 this.set({ loopEnabled: !this.state.loopEnabled });
312 }
314 rangeSelectNotes(): void {
315 const song = this.song();
316 const r = this.state.rangeTicks;
317 if (!song || !r) return;
318 const ids = song.tracks.flatMap((t) => t.notes.filter((n) => n.tick < r[1] && n.tick + n.duration > r[0]).map((n) => n.id));
319 this.set({ selection: ids });
320 }
322 /** Delete the notes inside the time selection; time itself is kept. */
323 rangeClearNotes(): void {
324 const r = this.state.rangeTicks;
325 if (!r) return;
326 this.mutateSong((s) => {
327 for (const t of s.tracks) t.notes = clearRange(t.notes, r[0], r[1]);
328 });
329 this.set({ selection: [] });
330 }
332 /** Delete the time selection on every track and close the gap; the tempo
333 * and time-signature maps ripple with it. */
334 rangeDeleteTime(): void {
335 const r = this.state.rangeTicks;
336 if (!r) return;
337 this.mutateSong((s) => {
338 for (const t of s.tracks) t.notes = deleteTimeRange(t.notes, r[0], r[1]);
339 s.tempo = deleteTimeFromEvents(s.tempo, r[0], r[1]);
340 s.timeSig = deleteTimeFromEvents(s.timeSig, r[0], r[1]);
341 });
342 this.set({ selection: [], rangeTicks: null });
343 }
345 /** Insert that much empty time at the selection start, on every track. */
346 rangeInsertTime(): void {
347 const r = this.state.rangeTicks;
348 if (!r) return;
349 const span = r[1] - r[0];
350 this.mutateSong((s) => {
351 for (const t of s.tracks) t.notes = insertTime(t.notes, r[0], span);
352 s.tempo = insertTimeIntoEvents(s.tempo, r[0], span);
353 s.timeSig = insertTimeIntoEvents(s.timeSig, r[0], span);
354 });
355 }
357 /** Insert a copy of the time selection immediately after itself and move
358 * the selection onto the copy. */
359 rangeDuplicateTime(): void {
360 const r = this.state.rangeTicks;
361 if (!r) return;
362 const span = r[1] - r[0];
363 this.mutateSong((s) => {
364 for (const t of s.tracks) {
365 const copy = extractRange(t.notes, r[0], r[1]).map((n) => ({ ...n, tick: n.tick + span }));
366 t.notes = insertTime(t.notes, r[1], span);
367 t.notes.push(...copy);
368 t.notes.sort((a, b) => a.tick - b.tick || a.pitch - b.pitch);
369 }
370 s.tempo = insertTimeIntoEvents(s.tempo, r[1], span);
371 s.timeSig = insertTimeIntoEvents(s.timeSig, r[1], span);
372 });
373 this.set({ rangeTicks: [r[1], r[1] + span] });
374 }
376 /** Split notes under the playhead: the selected ones, or every note of
377 * the active track when nothing is selected. */
378 splitAtPlayhead(): void {
379 const song = this.song();
380 if (!song) return;
381 const tm = this.tempoMap()!;
382 const tick = Math.round(tm.secToTick(Math.max(0, player.getPositionSec())));
383 const sel = this.state.selection.length > 0 ? new Set(this.state.selection) : null;
384 const activeId = this.state.activeTrackId;
385 const ids: string[] = [];
386 this.mutateSong((s) => {
387 for (const t of s.tracks) {
388 if (!sel && t.id !== activeId) continue;
389 const next: typeof t.notes = [];
390 for (const n of t.notes) {
391 const target = sel ? sel.has(n.id) : true;
392 if (target && n.tick < tick && tick < n.tick + n.duration) {
393 const id2 = newId('n');
394 next.push({ ...n, duration: tick - n.tick });
395 next.push({ ...n, id: id2, tick, duration: n.tick + n.duration - tick });
396 ids.push(n.id, id2);
397 } else {
398 next.push(n);
399 }
400 }
401 t.notes = next;
402 }
403 });
404 if (ids.length > 0) this.set({ selection: ids, status: `Split ${ids.length / 2} note(s)` });
405 }
407 seekToEnd(): void {
408 const song = this.song();
409 if (!song) return;
410 this.seekTo(this.tempoMap()!.tickToSec(songEndTick(song)));
411 }
413 // --- track arrangement ---
415 moveTrack(id: string, dir: -1 | 1): void {
416 this.mutateSong((s) => {
417 const i = s.tracks.findIndex((t) => t.id === id);
418 const j = i + dir;
419 if (i < 0 || j < 0 || j >= s.tracks.length) return;
420 const [t] = s.tracks.splice(i, 1);
421 s.tracks.splice(j, 0, t);
422 });
423 }
425 duplicateTrack(id: string): void {
426 let dupId: string | null = null;
427 this.mutateSong((s) => {
428 const i = s.tracks.findIndex((t) => t.id === id);
429 if (i < 0) return;
430 const src = s.tracks[i];
431 dupId = newId('t');
432 s.tracks.splice(i + 1, 0, {
433 ...structuredClone(src),
434 id: dupId,
435 name: `${src.name} copy`,
436 notes: src.notes.map((n) => ({ ...n, id: newId('n') })),
437 });
438 });
439 if (dupId) this.set({ activeTrackId: dupId });
440 }
442 selectAll(): void {
443 const song = this.song();
444 if (!song) return;
445 const t = song.tracks.find((x) => x.id === this.state.activeTrackId) ?? song.tracks[0];
446 if (t) this.set({ selection: t.notes.map((n) => n.id) });
447 }
449 // --- clipboard (in-memory, note times relative to the earliest note) ---
451 private clipboard: { dTick: number; duration: number; pitch: number; velocity: number }[] = [];
453 copySelection(): number {
454 const song = this.song();
455 if (!song || this.state.selection.length === 0) return 0;
456 const sel = new Set(this.state.selection);
457 const notes = song.tracks.flatMap((t) => t.notes.filter((n) => sel.has(n.id)));
458 if (notes.length === 0) return 0;
459 const t0 = Math.min(...notes.map((n) => n.tick));
460 this.clipboard = notes.map((n) => ({ dTick: n.tick - t0, duration: n.duration, pitch: n.pitch, velocity: n.velocity }));
461 this.set({ status: `Copied ${notes.length} note(s)` });
462 return notes.length;
463 }
465 cutSelection(): void {
466 if (this.copySelection() > 0) this.deleteSelection();
467 }
469 pasteAtPlayhead(): void {
470 const song = this.song();
471 if (!song || this.clipboard.length === 0) return;
472 const tm = this.tempoMap()!;
473 let tick = Math.round(tm.secToTick(Math.max(0, player.getPositionSec())));
474 const grid = this.snapTicks();
475 if (grid > 0) tick = Math.round(tick / grid) * grid;
476 const activeId = this.state.activeTrackId;
477 const ids: string[] = [];
478 this.mutateSong((s) => {
479 const t = s.tracks.find((x) => x.id === activeId) ?? s.tracks[0];
480 if (!t) return;
481 for (const c of this.clipboard) {
482 const id = newId('n');
483 ids.push(id);
484 t.notes.push({ id, tick: Math.max(0, tick + c.dTick), duration: c.duration, pitch: c.pitch, velocity: c.velocity });
485 }
486 t.notes.sort((a, b) => a.tick - b.tick || a.pitch - b.pitch);
487 });
488 this.set({ selection: ids, status: `Pasted ${ids.length} note(s)` });
489 }
491 /** Duplicate the selection immediately after itself (span rounded up to
492 * the snap grid), the usual ctrl+D. */
493 duplicateSelection(): void {
494 const song = this.song();
495 if (!song || this.state.selection.length === 0) return;
496 const sel = new Set(this.state.selection);
497 const notes = song.tracks.flatMap((t) => t.notes.filter((n) => sel.has(n.id)));
498 if (notes.length === 0) return;
499 const t0 = Math.min(...notes.map((n) => n.tick));
500 const end = Math.max(...notes.map((n) => n.tick + n.duration));
501 let span = Math.max(1, end - t0);
502 const grid = this.snapTicks();
503 if (grid > 0) span = Math.ceil(span / grid) * grid;
504 const ids: string[] = [];
505 this.mutateSong((s) => {
506 for (const t of s.tracks) {
507 const copies = t.notes
508 .filter((n) => sel.has(n.id))
509 .map((n) => {
510 const id = newId('n');
511 ids.push(id);
512 return { ...n, id, tick: n.tick + span };
513 });
514 if (copies.length > 0) {
515 t.notes.push(...copies);
516 t.notes.sort((a, b) => a.tick - b.tick || a.pitch - b.pitch);
517 }
518 }
519 });
520 this.set({ selection: ids });
521 }
523 nudgeSelection(dTick: number, dPitch: number): void {
524 if (this.state.selection.length === 0) return;
525 const sel = new Set(this.state.selection);
526 this.mutateSong((s) => {
527 for (const t of s.tracks) {
528 for (const n of t.notes) {
529 if (!sel.has(n.id)) continue;
530 n.tick = Math.max(0, n.tick + dTick);
531 n.pitch = Math.max(0, Math.min(127, n.pitch + dPitch));
532 }
533 }
534 });
535 }
537 setSnapBeats(v: number): void { this.set({ snapBeats: v }); }
539 deleteSelection(): void {
540 if (this.state.selection.length === 0) return;
541 const sel = new Set(this.state.selection);
542 this.mutateSong((song) => {
543 for (const t of song.tracks) t.notes = t.notes.filter((n) => !sel.has(n.id));
544 });
545 this.set({ selection: [] });
546 }
548 quantize(): void {
549 const grid = this.snapTicks();
550 if (grid <= 0) { this.set({ status: 'Quantize: snap is off' }); return; }
551 const sel = this.state.selection.length > 0 ? new Set(this.state.selection) : null;
552 const activeId = this.state.activeTrackId;
553 this.mutateSong((song) => {
554 for (const t of song.tracks) {
555 if (sel) t.notes = quantizeNotes(t.notes, grid, sel);
556 else if (t.id === activeId) t.notes = quantizeNotes(t.notes, grid);
557 }
558 });
559 }
561 // --- transport ---
563 async togglePlay(): Promise<void> {
564 const song = this.song();
565 if (!song) return;
566 if (player.state !== 'stopped') this.stopTransport();
567 else {
568 try {
569 let loop: { startSec: number; endSec: number } | undefined;
570 const r = this.state.rangeTicks;
571 if (this.state.loopEnabled && r) {
572 const tm = this.tempoMap()!;
573 loop = { startSec: tm.tickToSec(r[0]), endSec: tm.tickToSec(r[1]) };
574 const pos = player.getPositionSec();
575 if (pos < loop.startSec - 1e-3 || pos >= loop.endSec - 1e-3) player.seek(loop.startSec);
576 }
577 await player.play(song, { loop });
578 } catch (err) {
579 player.stop();
580 this.set({ status: `Playback failed: ${err instanceof Error ? err.message : String(err)}` });
581 }
582 }
583 }
585 async record(): Promise<void> {
586 const song = this.song();
587 if (!song) return;
588 if (player.state !== 'stopped') this.stopTransport();
589 const armed = this.state.armedTrackId ?? this.state.activeTrackId;
590 if (!armed) { this.set({ status: 'Record: no track to record into' }); return; }
591 this.set({ armedTrackId: armed });
592 try {
593 await player.play(song, { record: true, countInBars: 1 });
594 } catch (err) {
595 player.stop();
596 this.set({ status: `Record failed: ${err instanceof Error ? err.message : String(err)}` });
597 return;
598 }
599 this.recorder.start();
600 }
602 stopTransport(): void {
603 if (player.state === 'recording') {
604 const endSec = Math.max(0, player.getPositionSec());
605 const take = this.recorder.stop(endSec);
606 player.stop();
607 this.commitTake(take);
608 } else {
609 player.stop();
610 }
611 }
613 private commitTake(take: CapturedNote[]): void {
614 if (take.length === 0) return;
615 const song = this.song();
616 const armed = this.state.armedTrackId;
617 if (!song || !armed) return;
618 const tm = new TempoMap(song.ppq, song.tempo, song.timeSig);
619 const ids: string[] = [];
620 this.mutateSong((s) => {
621 const track = s.tracks.find((t) => t.id === armed);
622 if (!track) return;
623 for (const c of take) {
624 const tick = Math.max(0, Math.round(tm.secToTick(Math.max(0, c.startSec))));
625 const endTick = Math.round(tm.secToTick(Math.max(0, c.endSec)));
626 const id = newId('n');
627 ids.push(id);
628 track.notes.push({ id, tick, duration: Math.max(Math.round(s.ppq / 16), endTick - tick), pitch: c.pitch, velocity: c.velocity });
629 }
630 track.notes.sort((a, b) => a.tick - b.tick || a.pitch - b.pitch);
631 });
632 this.set({ selection: ids, status: `Recorded ${take.length} note(s)` });
633 }
635 seekTo(sec: number): void {
636 if (player.state === 'recording') return;
637 const song = this.song();
638 if (player.state === 'playing' && song) {
639 player.stop();
640 player.seek(sec);
641 void player.play(song);
642 } else {
643 player.seek(sec);
644 }
645 }
646}
648export const store = new Store();
650export function useApp(): AppState {
651 return useSyncExternalStore(store.subscribe, store.getSnapshot);
652}
654export function usePlayerVersion(): number {
655 return useSyncExternalStore(player.subscribe, () => player.version);
656}