Show length on a staff under each fingering chart.
AppImage release / appimage (push) Successful in 50s

Dragging no longer selects text. The line box resizes, rests and spaces can be added, and Save PNG writes the row. Each chart keeps the foot at the top, and the treble staff shows the pitch with a notehead, stem, flags, and dots. Version 0.2.1.

Co-authored-by: catpersonisme <catpersonisme@gmail.com>
This commit is contained in:
Cursor Agentandchen committed 2026-10-11 09:49:17 +00:00
1 parent b354ea9f88
commit dcca7f5e83
11 files changed
+997 -177

No files matched your search

+104
View File
@@ -0,0 +1,104 @@
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;
const TOP_INDEX = 20;
const LOW_STEM_STEPS = 7;
const PAD_TOP = 4;
const PAD_BOTTOM = 8;
const ABOVE = TOP_INDEX * STEP;
const BELOW = LOW_STEM_STEPS * STEP - 5 * 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. Index 0 is the bottom line, E4. C5, the recorder's low C, is 5. */
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 - 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;
}