import type { Key, ModeId } from "../theory/key.ts"; import { parseNote, pcOf } from "../theory/pitch.ts"; export type NoteEvent = { id: string; start: number; dur: number; pitch: number; vel: number }; /** how hard new notes are played: a midi velocity, as they're saved */ export const NOTE_VEL = 96 / 127; /** a chord is what was typed, what was voiced by hand, or both. times are in beats */ export type ChordEvent = { id: string; start: number; dur: number; symbol?: string; notes?: number[]; }; type TrackBase = { id: string; name: string; muted: boolean; /** volume trim, in dB */ gain: number; /** visible pitch range of the piano roll */ view: { low: number; high: number }; }; export type ChordTrack = TrackBase & { kind: "chords"; chords: ChordEvent[]; /** whether each chord's lowest note sounds: off when another track carries the bass */ bass: boolean; }; export type NoteTrack = TrackBase & { kind: "notes"; notes: NoteEvent[] }; export type Track = ChordTrack | NoteTrack; export type Project = { id: string; name: string; modified: number; bpm: number; beatsPerBar: number; key: { tonic: string; mode: ModeId }; /** default length of newly typed chords, in beats */ chordLength: number; /** note grid, in beats */ grid: number; loop: boolean; /** what gets looped, in beats; the whole song when absent */ loopRegion?: { start: number; end: number }; tracks: Track[]; }; // not randomUUID: that only exists in secure contexts, and this may be served over plain http export const newId = (): string => Array.from(crypto.getRandomValues(new Uint8Array(4)), b => b.toString(16).padStart(2, "0"), ).join(""); export const newChordTrack = (name = "chords"): ChordTrack => ({ id: newId(), name, kind: "chords", muted: false, gain: 0, bass: true, view: { low: 38, high: 77 }, chords: [], }); export const newNoteTrack = (name = "melody"): NoteTrack => ({ id: newId(), name, kind: "notes", muted: false, gain: 0, view: { low: 57, high: 88 }, notes: [], }); export const UNTITLED = "untitled"; type Timed = { start: number; dur: number }; const events = (t: Track): readonly Timed[] => (t.kind === "chords" ? t.chords : t.notes); /** nothing worth keeping: never named, and nothing written in it */ export const isBlank = (p: Project): boolean => p.name === UNTITLED && p.tracks.every(t => events(t).length === 0); export const newProject = (): Project => ({ id: newId(), name: UNTITLED, modified: Date.now(), bpm: 96, beatsPerBar: 4, key: { tonic: "C", mode: "ionian" }, chordLength: 1, grid: 0.25, loop: true, tracks: [newChordTrack(), newNoteTrack()], }); /** the tonics the key picker offers */ export const TONICS = [ "C", "C#", "Db", "D", "D#", "Eb", "E", "F", "F#", "Gb", "G", "G#", "Ab", "A", "A#", "Bb", "B", ]; /** a tonic as the key picker spells it (D♯ → E♭, C♭ → B) */ export function pickableTonic(tonic: string): string { const note = parseNote(tonic)?.note; if (!note || TONICS.includes(tonic)) return note ? tonic : "C"; return TONICS.find(t => pcOf(parseNote(t)!.note) === pcOf(note))!; } /** a piano roll range that fits these pitches, at least two and a half octaves tall */ export function fitView(pitches: readonly number[]): { low: number; high: number } { const [lo, hi] = [Math.min(...pitches), Math.max(...pitches)]; const span = Math.max(31, hi - lo + 6); const low = Math.min(127 - span, Math.max(0, Math.round((lo + hi - span) / 2))); return { low, high: low + span }; } /** a key as projects store it, for working with */ export const keyOf = (key: Project["key"]): Key => ({ tonic: parseNote(key.tonic)!.note, mode: key.mode, }); export const projectKey = (p: Project): Key => keyOf(p.key); export const projectEnd = (p: Project): number => Math.max(0, ...p.tracks.flatMap(t => events(t).map(e => e.start + e.dur))); export function findTrack( p: Project, id: string, kind: K, ): Extract | undefined { const t = p.tracks.find(t => t.id === id); return t?.kind === kind ? (t as Extract) : undefined; } /** * chord boundaries are compared exactly, but sums of triplet positions drift in floating point * (4/3 + 1/3 falls just short of 5/3). rounding every boundary onto a fixed grid that holds both * 64ths and triplets keeps equal positions equal */ export const canonBeat = (beats: number): number => Math.round(beats * 192) / 192; /** put a chord in place, cutting away whatever it overlaps */ export function placeChord(track: ChordTrack, placed: ChordEvent): void { const chord = { ...placed, start: canonBeat(placed.start), dur: canonBeat(placed.dur) }; const end = canonBeat(chord.start + chord.dur); track.chords = track.chords.flatMap(c => { if (c.id === chord.id) return []; const cEnd = canonBeat(c.start + c.dur); if (cEnd <= chord.start || c.start >= end) return [c]; const kept: ChordEvent[] = []; if (c.start < chord.start) kept.push({ ...c, dur: canonBeat(chord.start - c.start) }); if (cEnd > end) { const id = kept.length ? newId() : c.id; kept.push({ ...c, id, start: end, dur: canonBeat(cEnd - end) }); } return kept; }); track.chords.push(chord); track.chords.sort((a, b) => a.start - b.start); } /** * put chords just before a target chord, to lead into it: in the back half of the chord before * it, so the target still lands where it was; or, with nothing right before, in the target's own * front half. false if there isn't room for them on the grid */ export function insertLeadIn( track: ChordTrack, targetId: string, symbols: readonly string[], grid: number, ): boolean { const target = track.chords.find(c => c.id === targetId); if (!target) return false; const before = track.chords.find(c => canonBeat(c.start + c.dur) === target.start); const half = (dur: number) => Math.floor(dur / 2 / grid) * grid; const needed = symbols.length * grid; let start: number; let span: number; if (before && half(before.dur) >= needed) { span = half(before.dur); before.dur = canonBeat(before.dur - span); start = canonBeat(target.start - span); } else if (half(target.dur) >= needed) { span = half(target.dur); start = target.start; target.start = canonBeat(target.start + span); target.dur = canonBeat(target.dur - span); } else return false; const each = span / symbols.length; symbols.forEach((symbol, i) => { const at = canonBeat(start + i * each); track.chords.push({ id: newId(), start: at, dur: canonBeat(start + (i + 1) * each) - at, symbol, }); }); track.chords.sort((a, b) => a.start - b.start); return true; } /** chords taken out of a track, their starts relative to the first */ export type ChordClip = { chords: Omit[]; span: number }; export function copyChords(track: ChordTrack, ids: readonly string[]): ChordClip | undefined { const chosen = track.chords.filter(c => ids.includes(c.id)); if (!chosen.length) return undefined; const first = chosen[0].start; return { chords: chosen.map(({ id: _, ...c }) => ({ ...c, start: canonBeat(c.start - first) })), span: canonBeat(Math.max(...chosen.map(c => c.start + c.dur)) - first), }; } /** put copied chords in at a beat, over whatever's there; gives the new chords' ids */ export function pasteChords(track: ChordTrack, clip: ChordClip, at: number): string[] { return clip.chords.map(c => { const id = newId(); placeChord(track, { ...structuredClone(c), id, start: at + c.start }); return id; }); } /** notes taken out of a track, their starts relative to the first */ export type NoteClip = { notes: Omit[]; span: number }; export function copyNotes(track: NoteTrack, ids: readonly string[]): NoteClip | undefined { const chosen = track.notes.filter(n => ids.includes(n.id)); if (!chosen.length) return undefined; const first = Math.min(...chosen.map(n => n.start)); return { notes: chosen .map(({ id: _, ...n }) => ({ ...n, start: n.start - first })) .sort((a, b) => a.start - b.start || a.pitch - b.pitch), span: Math.max(...chosen.map(n => n.start + n.dur)) - first, }; } /** add copied notes at a beat, alongside what's there; gives the new notes' ids */ export function pasteNotes(track: NoteTrack, clip: NoteClip, at: number): string[] { return clip.notes.map(n => { const id = newId(); track.notes.push({ ...n, id, start: at + n.start }); return id; }); } /** move a track to before the one at `to` (or to the end, at the track count) */ export function moveTrack(p: Project, id: string, to: number): void { const from = p.tracks.findIndex(t => t.id === id); const [track] = p.tracks.splice(from, 1); p.tracks.splice(from < to ? to - 1 : to, 0, track); }