import { decodeCBOR, encodeCBOR } from "@char/cbor"; import { unpinRedundant, voiceTrack } from "./harmony.ts"; import type { ChordEvent, ChordTrack, NoteEvent, NoteTrack, Project } from "./model.ts"; import { newId, projectKey } from "./model.ts"; /** * how projects are saved: as json in the browser, and as cbor in files. every chord keeps the * notes it sounded with, pinned or not: an unpinned chord's notes are worked out from its symbol, * and if the way that's done ever changes, loading notices and pins the chord to what it was, * rather than it quietly sounding different * * there are a lot of chords and notes, so they're tuples rather than objects. times are in ticks, * which every grid (and the usual midi resolutions) divide into exactly, and velocities are midi's. * ids aren't kept: they only tell things apart while editing, and are made anew on loading */ export type StoredProject = Omit & { version: 3; tracks: StoredTrack[] }; type StoredTrack = | (Omit & { chords: StoredChord[] }) | (Omit & { notes: StoredNote[] }); export type StoredChord = [ start: number, dur: number, symbol: string | null, notes: number[], pinned: boolean, ]; type StoredNote = [start: number, dur: number, pitch: number, vel: number]; /** as projects were saved before: objects throughout, times in beats */ type StoredProjectV2 = Omit & { version: 2; tracks: ( | (Omit & { gain?: number; bass?: boolean; chords: (Omit & { notes: number[]; pinned: boolean })[]; }) | (Omit & { gain?: number }) )[]; }; export type SavedProject = StoredProject | StoredProjectV2; const TICKS_PER_BEAT = 1920; const toTicks = (beats: number) => Math.round(beats * TICKS_PER_BEAT); const toBeats = (ticks: number) => ticks / TICKS_PER_BEAT; /** a project's own lengths (as opposed to its events'), converted between beats and ticks */ const retimed =

>( p: P, f: (n: number) => number, ): P => ({ ...p, chordLength: f(p.chordLength), grid: f(p.grid), loopRegion: p.loopRegion && { start: f(p.loopRegion.start), end: f(p.loopRegion.end) }, }); const noteTuple = (n: Omit): StoredNote => [ toTicks(n.start), toTicks(n.dur), n.pitch, Math.round(n.vel * 127), ]; const chordTuple = ( c: Omit, notes: number[], pinned: boolean, ): StoredChord => [toTicks(c.start), toTicks(c.dur), c.symbol ?? null, notes, pinned]; const sorted = (notes: readonly number[]) => [...notes].sort((a, b) => a - b); const sameNotes = (a: readonly number[], b: readonly number[]) => a.length === b.length && sorted(a).every((n, i) => n === sorted(b)[i]); export function toStored(p: Project): StoredProject { const key = projectKey(p); return { ...retimed(p, toTicks), version: 3, tracks: p.tracks.map((track): StoredTrack => { if (track.kind === "notes") { const { id: _, notes, ...rest } = track; return { ...rest, notes: notes.map(noteTuple) }; } const voiced = voiceTrack(track, key); const { id: _, chords, ...rest } = track; return { ...rest, chords: chords.map((c, i) => chordTuple(c, voiced[i].notes, !!c.notes)), }; }), }; } function fromVersion2({ version: _, tracks, ...p }: StoredProjectV2): StoredProject { return { ...retimed(p, toTicks), version: 3, // tracks from before they had a volume trim, or a bass to turn off tracks: tracks.map(({ id: _, gain = 0, ...track }): StoredTrack => { if (track.kind === "notes") return { ...track, gain, notes: track.notes.map(noteTuple) }; const chords = track.chords.map(c => chordTuple(c, c.notes, c.pinned)); return { ...track, gain, bass: track.bass ?? true, chords }; }), }; } export function fromStored(saved: SavedProject): Project { const stored = saved.version === 2 ? fromVersion2(saved) : saved; if (stored.version !== 3) { throw new Error(`can't load a version ${stored.version} project: it's too old, or too new`); } const { version: _, ...rest } = stored; const unpinned = new Map(); const p: Project = { ...retimed(rest, toBeats), tracks: stored.tracks.map(track => { if (track.kind === "notes") { const notes = track.notes.map(([start, dur, pitch, vel]) => ({ id: newId(), start: toBeats(start), dur: toBeats(dur), pitch, vel: vel / 127, })); return { ...track, id: newId(), notes }; } const chords = track.chords.map(([start, dur, symbol, notes, pinned]): ChordEvent => { const c: ChordEvent = { id: newId(), start: toBeats(start), dur: toBeats(dur) }; if (symbol !== null) c.symbol = symbol; if (pinned) return { ...c, notes }; unpinned.set(c.id, notes); return c; }); return { ...track, id: newId(), chords }; }), }; // pin whatever would come out differently now. new tracks rather than changed ones: voicings // are cached against track objects const key = projectKey(p); const tracks = p.tracks.map(track => { if (track.kind !== "chords") return track; const voiced = voiceTrack(track, key); const chords = track.chords.map((c, i) => { const was = unpinned.get(c.id); return was && !sameNotes(was, voiced[i].notes) ? { ...c, notes: was } : c; }); return { ...track, chords }; }); return unpinRedundant({ ...p, tracks }); } /** a project as a file to give someone: as it's saved, but without where or when */ export function projectToFile(p: Project): Uint8Array { const { id: _, modified: __, ...stored } = toStored(p); return encodeCBOR(stored) as Uint8Array; } /** a project from a file, as a new one: it never takes the place of one already here */ export function projectFromFile(bytes: Uint8Array): Project { let stored; try { // files from before projects were cbor are json, and start with its brace stored = bytes[0] === 0x7b ? JSON.parse(new TextDecoder().decode(bytes)) : decodeCBOR(bytes); } catch { throw new Error("it isn't a chordpad project"); } if (typeof stored?.version !== "number" || !Array.isArray(stored.tracks)) { throw new Error("it isn't a chordpad project"); } return fromStored({ ...stored, id: newId(), modified: Date.now() }); }