0ad1ec1midiboot: in-browser MIDI workstation with pluggable instrumentsJeremy Magland 1import { useSyncExternalStore } from 'react';
2import type { Song, SongMeta, SongRecord, Track } from '../types.ts';
f67f407Time selections, loop playback, and further DAW-standard editingJeremy Magland 3import { defaultSong, newId, songEndTick } from '../types.ts';
0ad1ec1midiboot: in-browser MIDI workstation with pluggable instrumentsJeremy Magland 4import * as db from '../storage/db.ts';
5import { parseMidi } from '../midi/parse.ts';
6import { TempoMap } from '../midi/time.ts';
f67f407Time selections, loop playback, and further DAW-standard editingJeremy Magland 7import { clearRange, deleteTimeFromEvents, deleteTimeRange, extractRange, insertTime, insertTimeIntoEvents, quantizeNotes } from '../midi/ops.ts';
0ad1ec1midiboot: in-browser MIDI workstation with pluggable instrumentsJeremy Magland 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[];
f67f407Time selections, loop playback, and further DAW-standard editingJeremy Magland 23 /** Time selection [start, end) in ticks, made by dragging in the ruler. */
24 rangeTicks: [number, number] | null;
25 loopEnabled: boolean;
0ad1ec1midiboot: in-browser MIDI workstation with pluggable instrumentsJeremy Magland 26 snapBeats: number; // snap grid in quarter notes; 0 = off
27 libraryOpen: boolean;
28 status: string;
0557e3bSwitch to exported instrument bundles with numbl driversJeremy Magland 29 /** Bumped when the instrument registry changes so pickers re-render. */
30 instrumentsRev: number;
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: [],
f67f407Time selections, loop playback, and further DAW-standard editingJeremy Magland 44 rangeTicks: null,
45 loopEnabled: false,
0ad1ec1midiboot: in-browser MIDI workstation with pluggable instrumentsJeremy Magland 46 snapBeats: 0.25,
47 libraryOpen: false,
48 status: '',
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; }
8ad9830Placeholder instrawave adapter: drum, dulcimer, chime as instrumentsJeremy Magland 73 setStatus(msg: string): void { this.set({ status: msg }); }
0557e3bSwitch to exported instrument bundles with numbl driversJeremy Magland 75 bumpInstruments(): void { this.set({ instrumentsRev: this.state.instrumentsRev + 1 }); }
0ad1ec1midiboot: in-browser MIDI workstation with pluggable instrumentsJeremy Magland 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> {
90 try {
91 songs = await db.listSongs();
92 } catch (err) {
93 // IndexedDB unavailable or blocked: run in memory rather than
94 // sitting on the loading screen; edits will not persist.
95 const rec = this.freshRecord('Untitled song');
96 this.set({ ready: true, songs: [], rec, activeTrackId: rec.song.tracks[0]?.id ?? null,
97 status: `storage unavailable (${err instanceof Error ? err.message : String(err)}); changes will not be saved` });
98 return;
99 }
0ad1ec1midiboot: in-browser MIDI workstation with pluggable instrumentsJeremy Magland 100 let rec: SongRecord | null = null;
101 try {
102 const last = localStorage.getItem(LAST_SONG_KEY);
103 if (last) rec = await db.getSong(last);
104 } catch { /* localStorage unavailable */ }
105 if (!rec && songs.length > 0) rec = await db.getSong(songs[0].id);
106 if (!rec) {
107 rec = this.freshRecord('Untitled song');
108 await db.putSong(rec);
109 }
110 this.set({ ready: true, songs, rec, activeTrackId: rec.song.tracks[0]?.id ?? null });
111 this.rememberLast(rec.id);
112 }
114 private freshRecord(name: string, song?: Song): SongRecord {
115 const now = Date.now();
116 return { id: newId('s'), name, createdAt: now, updatedAt: now, song: song ?? defaultSong() };
117 }
119 private rememberLast(id: string) {
120 try { localStorage.setItem(LAST_SONG_KEY, id); } catch { /* ignore */ }
121 }
123 private scheduleSave() {
124 if (this.saveTimer !== null) clearTimeout(this.saveTimer);
125 this.saveTimer = setTimeout(() => {
126 this.saveTimer = null;
127 void this.saveNow();
128 }, 400);
129 }
131 private async saveNow() {
132 const rec = this.state.rec;
133 if (!rec) return;
134 await db.putSong(rec);
135 this.set({ songs: await db.listSongs() });
136 }
138 // --- edit protocol ---
140 beginEdit(): void {
141 if (!this.state.rec || this.pendingUndo) return;
142 this.pendingUndo = structuredClone(this.state.rec.song);
143 }
145 editSong(fn: (song: Song) => void): void {
146 const rec = this.state.rec;
147 if (!rec) return;
148 const song = structuredClone(rec.song);
149 fn(song);
150 this.set({ rec: { ...rec, song, updatedAt: Date.now() } });
151 }
153 endEdit(): void {
154 if (this.pendingUndo) {
155 this.undoStack.push(this.pendingUndo);
156 if (this.undoStack.length > UNDO_CAP) this.undoStack.shift();
157 this.redoStack = [];
158 this.pendingUndo = null;
159 }
160 this.scheduleSave();
161 }
163 mutateSong(fn: (song: Song) => void): void {
164 this.beginEdit();
165 this.editSong(fn);
166 this.endEdit();
167 }
169 undo(): void {
170 const rec = this.state.rec;
171 const prev = this.undoStack.pop();
172 if (!rec || !prev) return;
173 this.redoStack.push(structuredClone(rec.song));
174 this.set({ rec: { ...rec, song: prev, updatedAt: Date.now() }, selection: [] });
175 this.scheduleSave();
176 }
178 redo(): void {
179 const rec = this.state.rec;
180 const next = this.redoStack.pop();
181 if (!rec || !next) return;
182 this.undoStack.push(structuredClone(rec.song));
183 this.set({ rec: { ...rec, song: next, updatedAt: Date.now() }, selection: [] });
184 this.scheduleSave();
185 }
187 private resetHistory() {
188 this.undoStack = [];
189 this.redoStack = [];
190 this.pendingUndo = null;
191 }
193 // --- library ---
195 setLibraryOpen(v: boolean): void { this.set({ libraryOpen: v }); }
197 async newSong(): Promise<void> {
198 player.stop();
199 const rec = this.freshRecord('Untitled song');
200 await db.putSong(rec);
201 this.resetHistory();
202 this.set({ rec, songs: await db.listSongs(), activeTrackId: rec.song.tracks[0]?.id ?? null, armedTrackId: null, selection: [], libraryOpen: false });
203 player.seek(0);
204 this.rememberLast(rec.id);
205 }
207 async openSong(id: string): Promise<void> {
208 const rec = await db.getSong(id);
209 if (!rec) return;
210 player.stop();
211 this.resetHistory();
212 this.set({ rec, activeTrackId: rec.song.tracks[0]?.id ?? null, armedTrackId: null, selection: [], libraryOpen: false });
213 player.seek(0);
214 this.rememberLast(rec.id);
215 }
217 async deleteSongById(id: string): Promise<void> {
218 await db.deleteSong(id);
219 const songs = await db.listSongs();
220 this.set({ songs });
221 if (this.state.rec?.id === id) {
222 if (songs.length > 0) await this.openSong(songs[0].id);
223 else await this.newSong();
224 }
225 }
227 renameSong(name: string): void {
228 const rec = this.state.rec;
229 if (!rec) return;
230 this.set({ rec: { ...rec, name, updatedAt: Date.now() } });
231 this.scheduleSave();
232 }
234 async duplicateSong(id: string): Promise<void> {
235 const src = await db.getSong(id);
236 if (!src) return;
237 const rec = this.freshRecord(`${src.name} copy`, structuredClone(src.song));
238 await db.putSong(rec);
239 this.set({ songs: await db.listSongs() });
240 }
242 async importMidi(fileName: string, data: Uint8Array): Promise<void> {
243 let song: Song;
244 try {
245 song = parseMidi(data);
246 } catch (err) {
247 this.set({ status: `Import failed: ${err instanceof Error ? err.message : String(err)}` });
248 return;
249 }
250 if (song.tracks.length === 0) {
251 this.set({ status: 'Import: no notes found in file' });
252 return;
253 }
254 player.stop();
255 const name = fileName.replace(/\.(mid|midi)$/i, '') || 'Imported song';
256 const rec = this.freshRecord(name, song);
257 await db.putSong(rec);
258 this.resetHistory();
259 this.set({ rec, songs: await db.listSongs(), activeTrackId: song.tracks[0].id, armedTrackId: null, selection: [], libraryOpen: false, status: `Imported ${song.tracks.length} track(s)` });
260 player.seek(0);
261 this.rememberLast(rec.id);
262 }
264 // --- tracks ---
266 setActiveTrack(id: string): void { this.set({ activeTrackId: id, selection: [] }); }
268 setArmedTrack(id: string | null): void { this.set({ armedTrackId: id }); }
270 addTrack(): void {
271 const id = newId('t');
272 this.mutateSong((song) => {
273 song.tracks.push({ id, name: `Track ${song.tracks.length + 1}`, instrumentId: 'pluck', volume: 1, mute: false, solo: false, notes: [] });
274 });
275 this.set({ activeTrackId: id });
276 }
278 removeTrack(id: string): void {
279 this.mutateSong((song) => {
280 song.tracks = song.tracks.filter((t) => t.id !== id);
281 });
282 const song = this.song();
283 if (this.state.activeTrackId === id) this.set({ activeTrackId: song?.tracks[0]?.id ?? null });
284 if (this.state.armedTrackId === id) this.set({ armedTrackId: null });
285 }
287 updateTrack(id: string, patch: Partial<Track>, undoable = true): void {
288 const fn = (song: Song) => {
289 const t = song.tracks.find((x) => x.id === id);
290 if (t) Object.assign(t, patch);
291 };
292 if (undoable) this.mutateSong(fn);
293 else {
294 this.editSong(fn);
295 this.scheduleSave();
296 }
297 }
78851f6Replace instrawave with simulated instruments driven by manifestsJeremy Magland 299 setTrackSetting(id: string, key: string, value: number): void {
300 this.editSong((song) => {
301 const t = song.tracks.find((x) => x.id === id);
302 if (t) t.settings = { ...(t.settings ?? {}), [key]: value };
303 });
304 this.scheduleSave();
305 }
0ad1ec1midiboot: in-browser MIDI workstation with pluggable instrumentsJeremy Magland 307 setSelection(ids: string[]): void { this.set({ selection: ids }); }
f67f407Time selections, loop playback, and further DAW-standard editingJeremy Magland 309 // --- time selection and arrangement edits ---
311 setRange(r: [number, number] | null): void {
312 this.set({ rangeTicks: r });
313 }
315 toggleLoop(): void {
316 this.set({ loopEnabled: !this.state.loopEnabled });
317 }
319 rangeSelectNotes(): void {
320 const song = this.song();
321 const r = this.state.rangeTicks;
322 if (!song || !r) return;
323 const ids = song.tracks.flatMap((t) => t.notes.filter((n) => n.tick < r[1] && n.tick + n.duration > r[0]).map((n) => n.id));
324 this.set({ selection: ids });
325 }
327 /** Delete the notes inside the time selection; time itself is kept. */
328 rangeClearNotes(): void {
329 const r = this.state.rangeTicks;
330 if (!r) return;
331 this.mutateSong((s) => {
332 for (const t of s.tracks) t.notes = clearRange(t.notes, r[0], r[1]);
333 });
334 this.set({ selection: [] });
335 }
337 /** Delete the time selection on every track and close the gap; the tempo
338 * and time-signature maps ripple with it. */
339 rangeDeleteTime(): void {
340 const r = this.state.rangeTicks;
341 if (!r) return;
342 this.mutateSong((s) => {
343 for (const t of s.tracks) t.notes = deleteTimeRange(t.notes, r[0], r[1]);
344 s.tempo = deleteTimeFromEvents(s.tempo, r[0], r[1]);
345 s.timeSig = deleteTimeFromEvents(s.timeSig, r[0], r[1]);
346 });
347 this.set({ selection: [], rangeTicks: null });
348 }
350 /** Insert that much empty time at the selection start, on every track. */
351 rangeInsertTime(): void {
352 const r = this.state.rangeTicks;
353 if (!r) return;
354 const span = r[1] - r[0];
355 this.mutateSong((s) => {
356 for (const t of s.tracks) t.notes = insertTime(t.notes, r[0], span);
357 s.tempo = insertTimeIntoEvents(s.tempo, r[0], span);
358 s.timeSig = insertTimeIntoEvents(s.timeSig, r[0], span);
359 });
360 }
362 /** Insert a copy of the time selection immediately after itself and move
363 * the selection onto the copy. */
364 rangeDuplicateTime(): void {
365 const r = this.state.rangeTicks;
366 if (!r) return;
367 const span = r[1] - r[0];
368 this.mutateSong((s) => {
369 for (const t of s.tracks) {
370 const copy = extractRange(t.notes, r[0], r[1]).map((n) => ({ ...n, tick: n.tick + span }));
371 t.notes = insertTime(t.notes, r[1], span);
372 t.notes.push(...copy);
373 t.notes.sort((a, b) => a.tick - b.tick || a.pitch - b.pitch);
374 }
375 s.tempo = insertTimeIntoEvents(s.tempo, r[1], span);
376 s.timeSig = insertTimeIntoEvents(s.timeSig, r[1], span);
377 });
378 this.set({ rangeTicks: [r[1], r[1] + span] });
379 }
381 /** Split notes under the playhead: the selected ones, or every note of
382 * the active track when nothing is selected. */
383 splitAtPlayhead(): void {
384 const song = this.song();
385 if (!song) return;
386 const tm = this.tempoMap()!;
387 const tick = Math.round(tm.secToTick(Math.max(0, player.getPositionSec())));
388 const sel = this.state.selection.length > 0 ? new Set(this.state.selection) : null;
389 const activeId = this.state.activeTrackId;
390 const ids: string[] = [];
391 this.mutateSong((s) => {
392 for (const t of s.tracks) {
393 if (!sel && t.id !== activeId) continue;
394 const next: typeof t.notes = [];
395 for (const n of t.notes) {
396 const target = sel ? sel.has(n.id) : true;
397 if (target && n.tick < tick && tick < n.tick + n.duration) {
398 const id2 = newId('n');
399 next.push({ ...n, duration: tick - n.tick });
400 next.push({ ...n, id: id2, tick, duration: n.tick + n.duration - tick });
401 ids.push(n.id, id2);
402 } else {
403 next.push(n);
404 }
405 }
406 t.notes = next;
407 }
408 });
409 if (ids.length > 0) this.set({ selection: ids, status: `Split ${ids.length / 2} note(s)` });
410 }
412 seekToEnd(): void {
413 const song = this.song();
414 if (!song) return;
415 this.seekTo(this.tempoMap()!.tickToSec(songEndTick(song)));
416 }
418 // --- track arrangement ---
420 moveTrack(id: string, dir: -1 | 1): void {
421 this.mutateSong((s) => {
422 const i = s.tracks.findIndex((t) => t.id === id);
423 const j = i + dir;
424 if (i < 0 || j < 0 || j >= s.tracks.length) return;
425 const [t] = s.tracks.splice(i, 1);
426 s.tracks.splice(j, 0, t);
427 });
428 }
430 duplicateTrack(id: string): void {
431 let dupId: string | null = null;
432 this.mutateSong((s) => {
433 const i = s.tracks.findIndex((t) => t.id === id);
434 if (i < 0) return;
435 const src = s.tracks[i];
436 dupId = newId('t');
437 s.tracks.splice(i + 1, 0, {
438 ...structuredClone(src),
439 id: dupId,
440 name: `${src.name} copy`,
441 notes: src.notes.map((n) => ({ ...n, id: newId('n') })),
442 });
443 });
444 if (dupId) this.set({ activeTrackId: dupId });
445 }
f73dbfaPiano roll: DAW-standard gestures and selection operationsJeremy Magland 447 selectAll(): void {
448 const song = this.song();
449 if (!song) return;
450 const t = song.tracks.find((x) => x.id === this.state.activeTrackId) ?? song.tracks[0];
451 if (t) this.set({ selection: t.notes.map((n) => n.id) });
452 }
454 // --- clipboard (in-memory, note times relative to the earliest note) ---
456 private clipboard: { dTick: number; duration: number; pitch: number; velocity: number }[] = [];
458 copySelection(): number {
459 const song = this.song();
460 if (!song || this.state.selection.length === 0) return 0;
461 const sel = new Set(this.state.selection);
462 const notes = song.tracks.flatMap((t) => t.notes.filter((n) => sel.has(n.id)));
463 if (notes.length === 0) return 0;
464 const t0 = Math.min(...notes.map((n) => n.tick));
465 this.clipboard = notes.map((n) => ({ dTick: n.tick - t0, duration: n.duration, pitch: n.pitch, velocity: n.velocity }));
466 this.set({ status: `Copied ${notes.length} note(s)` });
467 return notes.length;
468 }
470 cutSelection(): void {
471 if (this.copySelection() > 0) this.deleteSelection();
472 }
474 pasteAtPlayhead(): void {
475 const song = this.song();
476 if (!song || this.clipboard.length === 0) return;
477 const tm = this.tempoMap()!;
478 let tick = Math.round(tm.secToTick(Math.max(0, player.getPositionSec())));
479 const grid = this.snapTicks();
480 if (grid > 0) tick = Math.round(tick / grid) * grid;
481 const activeId = this.state.activeTrackId;
482 const ids: string[] = [];
483 this.mutateSong((s) => {
484 const t = s.tracks.find((x) => x.id === activeId) ?? s.tracks[0];
485 if (!t) return;
486 for (const c of this.clipboard) {
487 const id = newId('n');
488 ids.push(id);
489 t.notes.push({ id, tick: Math.max(0, tick + c.dTick), duration: c.duration, pitch: c.pitch, velocity: c.velocity });
490 }
491 t.notes.sort((a, b) => a.tick - b.tick || a.pitch - b.pitch);
492 });
493 this.set({ selection: ids, status: `Pasted ${ids.length} note(s)` });
494 }
496 /** Duplicate the selection immediately after itself (span rounded up to
497 * the snap grid), the usual ctrl+D. */
498 duplicateSelection(): void {
499 const song = this.song();
500 if (!song || this.state.selection.length === 0) return;
501 const sel = new Set(this.state.selection);
502 const notes = song.tracks.flatMap((t) => t.notes.filter((n) => sel.has(n.id)));
503 if (notes.length === 0) return;
504 const t0 = Math.min(...notes.map((n) => n.tick));
505 const end = Math.max(...notes.map((n) => n.tick + n.duration));
506 let span = Math.max(1, end - t0);
507 const grid = this.snapTicks();
508 if (grid > 0) span = Math.ceil(span / grid) * grid;
509 const ids: string[] = [];
510 this.mutateSong((s) => {
511 for (const t of s.tracks) {
512 const copies = t.notes
513 .filter((n) => sel.has(n.id))
514 .map((n) => {
515 const id = newId('n');
516 ids.push(id);
517 return { ...n, id, tick: n.tick + span };
518 });
519 if (copies.length > 0) {
520 t.notes.push(...copies);
521 t.notes.sort((a, b) => a.tick - b.tick || a.pitch - b.pitch);
522 }
523 }
524 });
525 this.set({ selection: ids });
526 }
528 nudgeSelection(dTick: number, dPitch: number): void {
529 if (this.state.selection.length === 0) return;
530 const sel = new Set(this.state.selection);
531 this.mutateSong((s) => {
532 for (const t of s.tracks) {
533 for (const n of t.notes) {
534 if (!sel.has(n.id)) continue;
535 n.tick = Math.max(0, n.tick + dTick);
536 n.pitch = Math.max(0, Math.min(127, n.pitch + dPitch));
537 }
538 }
539 });
540 }
0ad1ec1midiboot: in-browser MIDI workstation with pluggable instrumentsJeremy Magland 542 setSnapBeats(v: number): void { this.set({ snapBeats: v }); }
544 deleteSelection(): void {
545 if (this.state.selection.length === 0) return;
546 const sel = new Set(this.state.selection);
547 this.mutateSong((song) => {
548 for (const t of song.tracks) t.notes = t.notes.filter((n) => !sel.has(n.id));
549 });
550 this.set({ selection: [] });
551 }
553 quantize(): void {
554 const grid = this.snapTicks();
555 if (grid <= 0) { this.set({ status: 'Quantize: snap is off' }); return; }
556 const sel = this.state.selection.length > 0 ? new Set(this.state.selection) : null;
557 const activeId = this.state.activeTrackId;
558 this.mutateSong((song) => {
559 for (const t of song.tracks) {
560 if (sel) t.notes = quantizeNotes(t.notes, grid, sel);
561 else if (t.id === activeId) t.notes = quantizeNotes(t.notes, grid);
562 }
563 });
564 }
566 // --- transport ---
568 async togglePlay(): Promise<void> {
569 const song = this.song();
570 if (!song) return;
571 if (player.state !== 'stopped') this.stopTransport();
8ad9830Placeholder instrawave adapter: drum, dulcimer, chime as instrumentsJeremy Magland 572 else {
573 try {
f67f407Time selections, loop playback, and further DAW-standard editingJeremy Magland 574 let loop: { startSec: number; endSec: number } | undefined;
575 const r = this.state.rangeTicks;
576 if (this.state.loopEnabled && r) {
577 const tm = this.tempoMap()!;
578 loop = { startSec: tm.tickToSec(r[0]), endSec: tm.tickToSec(r[1]) };
579 const pos = player.getPositionSec();
580 if (pos < loop.startSec - 1e-3 || pos >= loop.endSec - 1e-3) player.seek(loop.startSec);
581 }
582 await player.play(song, { loop });
8ad9830Placeholder instrawave adapter: drum, dulcimer, chime as instrumentsJeremy Magland 583 } catch (err) {
584 player.stop();
585 this.set({ status: `Playback failed: ${err instanceof Error ? err.message : String(err)}` });
586 }
587 }
590 async record(): Promise<void> {
591 const song = this.song();
592 if (!song) return;
593 if (player.state !== 'stopped') this.stopTransport();
594 const armed = this.state.armedTrackId ?? this.state.activeTrackId;
595 if (!armed) { this.set({ status: 'Record: no track to record into' }); return; }
596 this.set({ armedTrackId: armed });
598 await player.play(song, { record: true, countInBars: 1 });
599 } catch (err) {
600 player.stop();
601 this.set({ status: `Record failed: ${err instanceof Error ? err.message : String(err)}` });
602 return;
603 }
0ad1ec1midiboot: in-browser MIDI workstation with pluggable instrumentsJeremy Magland 604 this.recorder.start();
605 }
607 stopTransport(): void {
608 if (player.state === 'recording') {
609 const endSec = Math.max(0, player.getPositionSec());
610 const take = this.recorder.stop(endSec);
611 player.stop();
612 this.commitTake(take);
613 } else {
614 player.stop();
615 }
616 }
618 private commitTake(take: CapturedNote[]): void {
619 if (take.length === 0) return;
620 const song = this.song();
621 const armed = this.state.armedTrackId;
622 if (!song || !armed) return;
623 const tm = new TempoMap(song.ppq, song.tempo, song.timeSig);
624 const ids: string[] = [];
625 this.mutateSong((s) => {
626 const track = s.tracks.find((t) => t.id === armed);
627 if (!track) return;
628 for (const c of take) {
629 const tick = Math.max(0, Math.round(tm.secToTick(Math.max(0, c.startSec))));
630 const endTick = Math.round(tm.secToTick(Math.max(0, c.endSec)));
631 const id = newId('n');
632 ids.push(id);
633 track.notes.push({ id, tick, duration: Math.max(Math.round(s.ppq / 16), endTick - tick), pitch: c.pitch, velocity: c.velocity });
634 }
635 track.notes.sort((a, b) => a.tick - b.tick || a.pitch - b.pitch);
636 });
637 this.set({ selection: ids, status: `Recorded ${take.length} note(s)` });
638 }
640 seekTo(sec: number): void {
641 if (player.state === 'recording') return;
642 const song = this.song();
643 if (player.state === 'playing' && song) {
644 player.stop();
645 player.seek(sec);
646 void player.play(song);
647 } else {
648 player.seek(sec);
649 }
650 }
651}
653export const store = new Store();
655export function useApp(): AppState {
656 return useSyncExternalStore(store.subscribe, store.getSnapshot);
657}
659export function usePlayerVersion(): number {
660 return useSyncExternalStore(player.subscribe, () => player.version);
661}