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