import { quartersMatch } from "@/lib/duration"; /** Recorder drawing stays in a 48×188 box. The staff is added underneath. */ export const CHART_VB_W = 48; const RECORDER_VB_H = 188; /** Distance between two staff lines. One diatonic step is half of this. */ export const STAFF_SPACE = 4.6; const STEP = STAFF_SPACE / 2; /** Highest written note is sounding D7, drawn as D6. */ const TOP_INDEX = 13; /** Lowest written note is sounding C5, drawn as C4 on the ledger line below. */ const LOW_INDEX = -2; const PAD_TOP = 6; const PAD_BOTTOM = 14; const ABOVE = TOP_INDEX * STEP; const BELOW = -LOW_INDEX * STEP + PAD_BOTTOM; export const BOTTOM_LINE_Y = RECORDER_VB_H + 3 + PAD_TOP + ABOVE; export const CHART_VB_H = Math.ceil(BOTTOM_LINE_Y + BELOW); export function chartPixelHeight(width: number): number { return Math.round((width * CHART_VB_H) / CHART_VB_W); } export function staffY(index: number): number { return BOTTOM_LINE_Y - index * STEP; } const LETTERS = ["C", "D", "E", "F", "G", "A", "B"] as const; export type Accidental = "sharp" | "flat"; /** * Treble staff, written one octave below sounding pitch. * Index 0 is the bottom line, E4. Sounding C5 is written C4, index -2. */ export function staffPlacement(pitch: string): { index: number; accidental: Accidental | null } | null { const match = /^([A-Ga-g])(#|b|♯|♭)?(-?\d+)$/.exec(pitch.trim()); if (!match) return null; const letter = match[1].toUpperCase(); const mark = match[2] ?? ""; const accidental: Accidental | null = mark === "#" || mark === "♯" ? "sharp" : mark === "b" || mark === "♭" ? "flat" : null; const octave = Number(match[3]); if (!Number.isInteger(octave)) return null; const diatonic = (octave - 1 - 4) * 7 + LETTERS.indexOf(letter as (typeof LETTERS)[number]); return { index: diatonic - 2, accidental }; } /** Ledger lines a note needs outside the five-line staff. */ export function ledgerIndexes(index: number): number[] { const lines: number[] = []; if (index > 8) { for (let line = 10; line <= index; line += 2) lines.push(line); } if (index < 0) { const start = index % 2 === 0 ? index : index - 1; for (let line = -2; line >= start; line -= 2) lines.push(line); } return lines; } /** On the middle line or higher, the stem goes down. */ export function stemDown(index: number): boolean { return index >= 4; } export type NoteValue = { head: "open" | "filled"; stem: boolean; flags: 0 | 1 | 2; dots: 0 | 1; }; export type RestValue = { rest: "whole" | "half" | "quarter" | "eighth" | "sixteenth"; dots: 0 | 1; }; /** One chart has its own staff, so a short note wears flags instead of a beam. */ export function noteValue(quarters: number): NoteValue { if (quartersMatch(quarters, 4)) return { head: "open", stem: false, flags: 0, dots: 0 }; if (quartersMatch(quarters, 3)) return { head: "open", stem: true, flags: 0, dots: 1 }; if (quartersMatch(quarters, 2)) return { head: "open", stem: true, flags: 0, dots: 0 }; if (quartersMatch(quarters, 1.5)) return { head: "filled", stem: true, flags: 0, dots: 1 }; if (quartersMatch(quarters, 0.75)) return { head: "filled", stem: true, flags: 1, dots: 1 }; if (quartersMatch(quarters, 0.5)) return { head: "filled", stem: true, flags: 1, dots: 0 }; if (quartersMatch(quarters, 0.25)) return { head: "filled", stem: true, flags: 2, dots: 0 }; return { head: "filled", stem: true, flags: 0, dots: 0 }; } export function restValue(quarters: number): RestValue { if (quartersMatch(quarters, 4)) return { rest: "whole", dots: 0 }; if (quartersMatch(quarters, 3)) return { rest: "half", dots: 1 }; if (quartersMatch(quarters, 2)) return { rest: "half", dots: 0 }; if (quartersMatch(quarters, 1.5)) return { rest: "quarter", dots: 1 }; if (quartersMatch(quarters, 0.75)) return { rest: "eighth", dots: 1 }; if (quartersMatch(quarters, 0.5)) return { rest: "eighth", dots: 0 }; if (quartersMatch(quarters, 0.25)) return { rest: "sixteenth", dots: 0 }; return { rest: "quarter", dots: 0 }; } /** A dot sits in a space. A note on a line takes the space above. */ export function dotIndex(index: number): number { return index % 2 === 0 ? index + 1 : index; }