import type { Note } from '../types.ts'; import { newId } from '../types.ts'; // Pure note-list operations used by the editor; kept free of DOM and state // so the node test harness can exercise them directly. export function quantizeNotes(notes: Note[], gridTicks: number, ids?: Set): Note[] { if (gridTicks <= 0) return notes; return notes.map((n) => { if (ids && !ids.has(n.id)) return n; const tick = Math.max(0, Math.round(n.tick / gridTicks) * gridTicks); return { ...n, tick }; }); } export function transposeNotes(notes: Note[], semitones: number, ids?: Set): Note[] { return notes.map((n) => { if (ids && !ids.has(n.id)) return n; const pitch = Math.max(0, Math.min(127, n.pitch + semitones)); return { ...n, pitch }; }); } // --- time-range operations (the arrangement edits) --- // All ranges are [start, end) in ticks. Notes crossing a boundary are // trimmed, and a note spanning the whole range is split in two, which is // what a user expects "delete the contents of this region" to mean. export function clearRange(notes: Note[], start: number, end: number): Note[] { const out: Note[] = []; for (const n of notes) { const nEnd = n.tick + n.duration; if (nEnd <= start || n.tick >= end) { out.push(n); } else if (n.tick < start) { out.push({ ...n, duration: start - n.tick }); if (nEnd > end) out.push({ ...n, id: newId('n'), tick: end, duration: nEnd - end }); } else if (nEnd > end) { out.push({ ...n, tick: end, duration: nEnd - end }); } // fully inside: dropped } return out; } /** Delete the range and close the gap (ripple). */ export function deleteTimeRange(notes: Note[], start: number, end: number): Note[] { const span = end - start; return clearRange(notes, start, end).map((n) => (n.tick >= end ? { ...n, tick: n.tick - span } : n)); } /** Insert empty time at a point; notes starting there or later shift right. */ export function insertTime(notes: Note[], at: number, span: number): Note[] { return notes.map((n) => (n.tick >= at ? { ...n, tick: n.tick + span } : n)); } /** Copies (fresh ids) of the parts of notes that fall inside the range. */ export function extractRange(notes: Note[], start: number, end: number): Note[] { const out: Note[] = []; for (const n of notes) { const t0 = Math.max(n.tick, start); const t1 = Math.min(n.tick + n.duration, end); if (t1 > t0) out.push({ ...n, id: newId('n'), tick: t0, duration: t1 - t0 }); } return out; } /** Ripple-delete a range from a tick-stamped event list (tempo map, time * signatures). Events strictly inside are dropped; an event exactly at the * start survives, so the map stays valid from tick 0. */ export function deleteTimeFromEvents(events: T[], start: number, end: number): T[] { const span = end - start; return events .filter((e) => !(e.tick > start && e.tick < end)) .map((e) => (e.tick >= end ? { ...e, tick: e.tick - span } : e)); } export function insertTimeIntoEvents(events: T[], at: number, span: number): T[] { return events.map((e) => (e.tick > 0 && e.tick >= at ? { ...e, tick: e.tick + span } : e)); }