AppImage release / appimage (push) Successful in 54s
Sounding C5 sits on the ledger line below the treble staff. Sharps and flats move with the note. Fingerings and the foot-up charts stay as they are. Version 0.2.2. Co-authored-by: catpersonisme <catpersonisme@gmail.com>
110 lines
4.1 KiB
TypeScript
110 lines
4.1 KiB
TypeScript
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;
|
||
}
|