import { useSyncExternalStore } from 'react'; import type { Song, SongMeta, SongRecord, Track } from '../types.ts'; import { defaultSong, newId, songEndTick } from '../types.ts'; import * as db from '../storage/db.ts'; import { parseMidi } from '../midi/parse.ts'; import { TempoMap } from '../midi/time.ts'; import { clearRange, deleteTimeFromEvents, deleteTimeRange, extractRange, insertTime, insertTimeIntoEvents, quantizeNotes } from '../midi/ops.ts'; import { player } from '../audio/player.ts'; import { Recorder, type CapturedNote } from '../input/recorder.ts'; // Application state and every action that edits it. Song edits go through // beginEdit/editSong/endEdit so a whole drag gesture is one undo step; // mutateSong wraps the three for one-shot edits. Songs autosave to // IndexedDB shortly after each committed edit. export interface AppState { ready: boolean; rec: SongRecord | null; songs: SongMeta[]; activeTrackId: string | null; armedTrackId: string | null; selection: string[]; /** Time selection [start, end) in ticks, made by dragging in the ruler. */ rangeTicks: [number, number] | null; loopEnabled: boolean; snapBeats: number; // snap grid in quarter notes; 0 = off libraryOpen: boolean; status: string; } const LAST_SONG_KEY = 'midiboot:lastSong'; const UNDO_CAP = 200; class Store { state: AppState = { ready: false, rec: null, songs: [], activeTrackId: null, armedTrackId: null, selection: [], rangeTicks: null, loopEnabled: false, snapBeats: 0.25, libraryOpen: false, status: '', }; recorder = new Recorder(); private listeners = new Set<() => void>(); private undoStack: Song[] = []; private redoStack: Song[] = []; private pendingUndo: Song | null = null; private saveTimer: ReturnType | null = null; subscribe = (cb: () => void): (() => void) => { this.listeners.add(cb); return () => this.listeners.delete(cb); }; getSnapshot = (): AppState => this.state; private set(patch: Partial) { this.state = { ...this.state, ...patch }; for (const cb of this.listeners) cb(); } song(): Song | null { return this.state.rec?.song ?? null; } setStatus(msg: string): void { this.set({ status: msg }); } tempoMap(): TempoMap | null { const song = this.song(); return song ? new TempoMap(song.ppq, song.tempo, song.timeSig) : null; } snapTicks(): number { const song = this.song(); if (!song || this.state.snapBeats <= 0) return 0; return Math.round(song.ppq * this.state.snapBeats); } async init(): Promise { let songs: SongMeta[] = []; try { songs = await db.listSongs(); } catch (err) { // IndexedDB unavailable or blocked: run in memory rather than // sitting on the loading screen; edits will not persist. const rec = this.freshRecord('Untitled song'); this.set({ ready: true, songs: [], rec, activeTrackId: rec.song.tracks[0]?.id ?? null, status: `storage unavailable (${err instanceof Error ? err.message : String(err)}); changes will not be saved` }); return; } let rec: SongRecord | null = null; try { const last = localStorage.getItem(LAST_SONG_KEY); if (last) rec = await db.getSong(last); } catch { /* localStorage unavailable */ } if (!rec && songs.length > 0) rec = await db.getSong(songs[0].id); if (!rec) { rec = this.freshRecord('Untitled song'); await db.putSong(rec); } this.set({ ready: true, songs, rec, activeTrackId: rec.song.tracks[0]?.id ?? null }); this.rememberLast(rec.id); } private freshRecord(name: string, song?: Song): SongRecord { const now = Date.now(); return { id: newId('s'), name, createdAt: now, updatedAt: now, song: song ?? defaultSong() }; } private rememberLast(id: string) { try { localStorage.setItem(LAST_SONG_KEY, id); } catch { /* ignore */ } } private scheduleSave() { if (this.saveTimer !== null) clearTimeout(this.saveTimer); this.saveTimer = setTimeout(() => { this.saveTimer = null; void this.saveNow(); }, 400); } private async saveNow() { const rec = this.state.rec; if (!rec) return; await db.putSong(rec); this.set({ songs: await db.listSongs() }); } // --- edit protocol --- beginEdit(): void { if (!this.state.rec || this.pendingUndo) return; this.pendingUndo = structuredClone(this.state.rec.song); } editSong(fn: (song: Song) => void): void { const rec = this.state.rec; if (!rec) return; const song = structuredClone(rec.song); fn(song); this.set({ rec: { ...rec, song, updatedAt: Date.now() } }); } endEdit(): void { if (this.pendingUndo) { this.undoStack.push(this.pendingUndo); if (this.undoStack.length > UNDO_CAP) this.undoStack.shift(); this.redoStack = []; this.pendingUndo = null; } this.scheduleSave(); } mutateSong(fn: (song: Song) => void): void { this.beginEdit(); this.editSong(fn); this.endEdit(); } undo(): void { const rec = this.state.rec; const prev = this.undoStack.pop(); if (!rec || !prev) return; this.redoStack.push(structuredClone(rec.song)); this.set({ rec: { ...rec, song: prev, updatedAt: Date.now() }, selection: [] }); this.scheduleSave(); } redo(): void { const rec = this.state.rec; const next = this.redoStack.pop(); if (!rec || !next) return; this.undoStack.push(structuredClone(rec.song)); this.set({ rec: { ...rec, song: next, updatedAt: Date.now() }, selection: [] }); this.scheduleSave(); } private resetHistory() { this.undoStack = []; this.redoStack = []; this.pendingUndo = null; } // --- library --- setLibraryOpen(v: boolean): void { this.set({ libraryOpen: v }); } async newSong(): Promise { player.stop(); const rec = this.freshRecord('Untitled song'); await db.putSong(rec); this.resetHistory(); this.set({ rec, songs: await db.listSongs(), activeTrackId: rec.song.tracks[0]?.id ?? null, armedTrackId: null, selection: [], libraryOpen: false }); player.seek(0); this.rememberLast(rec.id); } async openSong(id: string): Promise { const rec = await db.getSong(id); if (!rec) return; player.stop(); this.resetHistory(); this.set({ rec, activeTrackId: rec.song.tracks[0]?.id ?? null, armedTrackId: null, selection: [], libraryOpen: false }); player.seek(0); this.rememberLast(rec.id); } async deleteSongById(id: string): Promise { await db.deleteSong(id); const songs = await db.listSongs(); this.set({ songs }); if (this.state.rec?.id === id) { if (songs.length > 0) await this.openSong(songs[0].id); else await this.newSong(); } } renameSong(name: string): void { const rec = this.state.rec; if (!rec) return; this.set({ rec: { ...rec, name, updatedAt: Date.now() } }); this.scheduleSave(); } async duplicateSong(id: string): Promise { const src = await db.getSong(id); if (!src) return; const rec = this.freshRecord(`${src.name} copy`, structuredClone(src.song)); await db.putSong(rec); this.set({ songs: await db.listSongs() }); } async importMidi(fileName: string, data: Uint8Array): Promise { let song: Song; try { song = parseMidi(data); } catch (err) { this.set({ status: `Import failed: ${err instanceof Error ? err.message : String(err)}` }); return; } if (song.tracks.length === 0) { this.set({ status: 'Import: no notes found in file' }); return; } player.stop(); const name = fileName.replace(/\.(mid|midi)$/i, '') || 'Imported song'; const rec = this.freshRecord(name, song); await db.putSong(rec); this.resetHistory(); this.set({ rec, songs: await db.listSongs(), activeTrackId: song.tracks[0].id, armedTrackId: null, selection: [], libraryOpen: false, status: `Imported ${song.tracks.length} track(s)` }); player.seek(0); this.rememberLast(rec.id); } // --- tracks --- setActiveTrack(id: string): void { this.set({ activeTrackId: id, selection: [] }); } setArmedTrack(id: string | null): void { this.set({ armedTrackId: id }); } addTrack(): void { const id = newId('t'); this.mutateSong((song) => { song.tracks.push({ id, name: `Track ${song.tracks.length + 1}`, instrumentId: 'pluck', volume: 1, mute: false, solo: false, notes: [] }); }); this.set({ activeTrackId: id }); } removeTrack(id: string): void { this.mutateSong((song) => { song.tracks = song.tracks.filter((t) => t.id !== id); }); const song = this.song(); if (this.state.activeTrackId === id) this.set({ activeTrackId: song?.tracks[0]?.id ?? null }); if (this.state.armedTrackId === id) this.set({ armedTrackId: null }); } updateTrack(id: string, patch: Partial, undoable = true): void { const fn = (song: Song) => { const t = song.tracks.find((x) => x.id === id); if (t) Object.assign(t, patch); }; if (undoable) this.mutateSong(fn); else { this.editSong(fn); this.scheduleSave(); } } setTrackSetting(id: string, key: string, value: number): void { this.editSong((song) => { const t = song.tracks.find((x) => x.id === id); if (t) t.settings = { ...(t.settings ?? {}), [key]: value }; }); this.scheduleSave(); } setSelection(ids: string[]): void { this.set({ selection: ids }); } // --- time selection and arrangement edits --- setRange(r: [number, number] | null): void { this.set({ rangeTicks: r }); } toggleLoop(): void { this.set({ loopEnabled: !this.state.loopEnabled }); } rangeSelectNotes(): void { const song = this.song(); const r = this.state.rangeTicks; if (!song || !r) return; const ids = song.tracks.flatMap((t) => t.notes.filter((n) => n.tick < r[1] && n.tick + n.duration > r[0]).map((n) => n.id)); this.set({ selection: ids }); } /** Delete the notes inside the time selection; time itself is kept. */ rangeClearNotes(): void { const r = this.state.rangeTicks; if (!r) return; this.mutateSong((s) => { for (const t of s.tracks) t.notes = clearRange(t.notes, r[0], r[1]); }); this.set({ selection: [] }); } /** Delete the time selection on every track and close the gap; the tempo * and time-signature maps ripple with it. */ rangeDeleteTime(): void { const r = this.state.rangeTicks; if (!r) return; this.mutateSong((s) => { for (const t of s.tracks) t.notes = deleteTimeRange(t.notes, r[0], r[1]); s.tempo = deleteTimeFromEvents(s.tempo, r[0], r[1]); s.timeSig = deleteTimeFromEvents(s.timeSig, r[0], r[1]); }); this.set({ selection: [], rangeTicks: null }); } /** Insert that much empty time at the selection start, on every track. */ rangeInsertTime(): void { const r = this.state.rangeTicks; if (!r) return; const span = r[1] - r[0]; this.mutateSong((s) => { for (const t of s.tracks) t.notes = insertTime(t.notes, r[0], span); s.tempo = insertTimeIntoEvents(s.tempo, r[0], span); s.timeSig = insertTimeIntoEvents(s.timeSig, r[0], span); }); } /** Insert a copy of the time selection immediately after itself and move * the selection onto the copy. */ rangeDuplicateTime(): void { const r = this.state.rangeTicks; if (!r) return; const span = r[1] - r[0]; this.mutateSong((s) => { for (const t of s.tracks) { const copy = extractRange(t.notes, r[0], r[1]).map((n) => ({ ...n, tick: n.tick + span })); t.notes = insertTime(t.notes, r[1], span); t.notes.push(...copy); t.notes.sort((a, b) => a.tick - b.tick || a.pitch - b.pitch); } s.tempo = insertTimeIntoEvents(s.tempo, r[1], span); s.timeSig = insertTimeIntoEvents(s.timeSig, r[1], span); }); this.set({ rangeTicks: [r[1], r[1] + span] }); } /** Split notes under the playhead: the selected ones, or every note of * the active track when nothing is selected. */ splitAtPlayhead(): void { const song = this.song(); if (!song) return; const tm = this.tempoMap()!; const tick = Math.round(tm.secToTick(Math.max(0, player.getPositionSec()))); const sel = this.state.selection.length > 0 ? new Set(this.state.selection) : null; const activeId = this.state.activeTrackId; const ids: string[] = []; this.mutateSong((s) => { for (const t of s.tracks) { if (!sel && t.id !== activeId) continue; const next: typeof t.notes = []; for (const n of t.notes) { const target = sel ? sel.has(n.id) : true; if (target && n.tick < tick && tick < n.tick + n.duration) { const id2 = newId('n'); next.push({ ...n, duration: tick - n.tick }); next.push({ ...n, id: id2, tick, duration: n.tick + n.duration - tick }); ids.push(n.id, id2); } else { next.push(n); } } t.notes = next; } }); if (ids.length > 0) this.set({ selection: ids, status: `Split ${ids.length / 2} note(s)` }); } seekToEnd(): void { const song = this.song(); if (!song) return; this.seekTo(this.tempoMap()!.tickToSec(songEndTick(song))); } // --- track arrangement --- moveTrack(id: string, dir: -1 | 1): void { this.mutateSong((s) => { const i = s.tracks.findIndex((t) => t.id === id); const j = i + dir; if (i < 0 || j < 0 || j >= s.tracks.length) return; const [t] = s.tracks.splice(i, 1); s.tracks.splice(j, 0, t); }); } duplicateTrack(id: string): void { let dupId: string | null = null; this.mutateSong((s) => { const i = s.tracks.findIndex((t) => t.id === id); if (i < 0) return; const src = s.tracks[i]; dupId = newId('t'); s.tracks.splice(i + 1, 0, { ...structuredClone(src), id: dupId, name: `${src.name} copy`, notes: src.notes.map((n) => ({ ...n, id: newId('n') })), }); }); if (dupId) this.set({ activeTrackId: dupId }); } selectAll(): void { const song = this.song(); if (!song) return; const t = song.tracks.find((x) => x.id === this.state.activeTrackId) ?? song.tracks[0]; if (t) this.set({ selection: t.notes.map((n) => n.id) }); } // --- clipboard (in-memory, note times relative to the earliest note) --- private clipboard: { dTick: number; duration: number; pitch: number; velocity: number }[] = []; copySelection(): number { const song = this.song(); if (!song || this.state.selection.length === 0) return 0; const sel = new Set(this.state.selection); const notes = song.tracks.flatMap((t) => t.notes.filter((n) => sel.has(n.id))); if (notes.length === 0) return 0; const t0 = Math.min(...notes.map((n) => n.tick)); this.clipboard = notes.map((n) => ({ dTick: n.tick - t0, duration: n.duration, pitch: n.pitch, velocity: n.velocity })); this.set({ status: `Copied ${notes.length} note(s)` }); return notes.length; } cutSelection(): void { if (this.copySelection() > 0) this.deleteSelection(); } pasteAtPlayhead(): void { const song = this.song(); if (!song || this.clipboard.length === 0) return; const tm = this.tempoMap()!; let tick = Math.round(tm.secToTick(Math.max(0, player.getPositionSec()))); const grid = this.snapTicks(); if (grid > 0) tick = Math.round(tick / grid) * grid; const activeId = this.state.activeTrackId; const ids: string[] = []; this.mutateSong((s) => { const t = s.tracks.find((x) => x.id === activeId) ?? s.tracks[0]; if (!t) return; for (const c of this.clipboard) { const id = newId('n'); ids.push(id); t.notes.push({ id, tick: Math.max(0, tick + c.dTick), duration: c.duration, pitch: c.pitch, velocity: c.velocity }); } t.notes.sort((a, b) => a.tick - b.tick || a.pitch - b.pitch); }); this.set({ selection: ids, status: `Pasted ${ids.length} note(s)` }); } /** Duplicate the selection immediately after itself (span rounded up to * the snap grid), the usual ctrl+D. */ duplicateSelection(): void { const song = this.song(); if (!song || this.state.selection.length === 0) return; const sel = new Set(this.state.selection); const notes = song.tracks.flatMap((t) => t.notes.filter((n) => sel.has(n.id))); if (notes.length === 0) return; const t0 = Math.min(...notes.map((n) => n.tick)); const end = Math.max(...notes.map((n) => n.tick + n.duration)); let span = Math.max(1, end - t0); const grid = this.snapTicks(); if (grid > 0) span = Math.ceil(span / grid) * grid; const ids: string[] = []; this.mutateSong((s) => { for (const t of s.tracks) { const copies = t.notes .filter((n) => sel.has(n.id)) .map((n) => { const id = newId('n'); ids.push(id); return { ...n, id, tick: n.tick + span }; }); if (copies.length > 0) { t.notes.push(...copies); t.notes.sort((a, b) => a.tick - b.tick || a.pitch - b.pitch); } } }); this.set({ selection: ids }); } nudgeSelection(dTick: number, dPitch: number): void { if (this.state.selection.length === 0) return; const sel = new Set(this.state.selection); this.mutateSong((s) => { for (const t of s.tracks) { for (const n of t.notes) { if (!sel.has(n.id)) continue; n.tick = Math.max(0, n.tick + dTick); n.pitch = Math.max(0, Math.min(127, n.pitch + dPitch)); } } }); } setSnapBeats(v: number): void { this.set({ snapBeats: v }); } deleteSelection(): void { if (this.state.selection.length === 0) return; const sel = new Set(this.state.selection); this.mutateSong((song) => { for (const t of song.tracks) t.notes = t.notes.filter((n) => !sel.has(n.id)); }); this.set({ selection: [] }); } quantize(): void { const grid = this.snapTicks(); if (grid <= 0) { this.set({ status: 'Quantize: snap is off' }); return; } const sel = this.state.selection.length > 0 ? new Set(this.state.selection) : null; const activeId = this.state.activeTrackId; this.mutateSong((song) => { for (const t of song.tracks) { if (sel) t.notes = quantizeNotes(t.notes, grid, sel); else if (t.id === activeId) t.notes = quantizeNotes(t.notes, grid); } }); } // --- transport --- async togglePlay(): Promise { const song = this.song(); if (!song) return; if (player.state !== 'stopped') this.stopTransport(); else { try { let loop: { startSec: number; endSec: number } | undefined; const r = this.state.rangeTicks; if (this.state.loopEnabled && r) { const tm = this.tempoMap()!; loop = { startSec: tm.tickToSec(r[0]), endSec: tm.tickToSec(r[1]) }; const pos = player.getPositionSec(); if (pos < loop.startSec - 1e-3 || pos >= loop.endSec - 1e-3) player.seek(loop.startSec); } await player.play(song, { loop }); } catch (err) { player.stop(); this.set({ status: `Playback failed: ${err instanceof Error ? err.message : String(err)}` }); } } } async record(): Promise { const song = this.song(); if (!song) return; if (player.state !== 'stopped') this.stopTransport(); const armed = this.state.armedTrackId ?? this.state.activeTrackId; if (!armed) { this.set({ status: 'Record: no track to record into' }); return; } this.set({ armedTrackId: armed }); try { await player.play(song, { record: true, countInBars: 1 }); } catch (err) { player.stop(); this.set({ status: `Record failed: ${err instanceof Error ? err.message : String(err)}` }); return; } this.recorder.start(); } stopTransport(): void { if (player.state === 'recording') { const endSec = Math.max(0, player.getPositionSec()); const take = this.recorder.stop(endSec); player.stop(); this.commitTake(take); } else { player.stop(); } } private commitTake(take: CapturedNote[]): void { if (take.length === 0) return; const song = this.song(); const armed = this.state.armedTrackId; if (!song || !armed) return; const tm = new TempoMap(song.ppq, song.tempo, song.timeSig); const ids: string[] = []; this.mutateSong((s) => { const track = s.tracks.find((t) => t.id === armed); if (!track) return; for (const c of take) { const tick = Math.max(0, Math.round(tm.secToTick(Math.max(0, c.startSec)))); const endTick = Math.round(tm.secToTick(Math.max(0, c.endSec))); const id = newId('n'); ids.push(id); track.notes.push({ id, tick, duration: Math.max(Math.round(s.ppq / 16), endTick - tick), pitch: c.pitch, velocity: c.velocity }); } track.notes.sort((a, b) => a.tick - b.tick || a.pitch - b.pitch); }); this.set({ selection: ids, status: `Recorded ${take.length} note(s)` }); } seekTo(sec: number): void { if (player.state === 'recording') return; const song = this.song(); if (player.state === 'playing' && song) { player.stop(); player.seek(sec); void player.play(song); } else { player.seek(sec); } } } export const store = new Store(); export function useApp(): AppState { return useSyncExternalStore(store.subscribe, store.getSnapshot); } export function usePlayerVersion(): number { return useSyncExternalStore(player.subscribe, () => player.version); }