Files
Recorder-Tabber/src/lib/notation.ts
T
Cursor Agentandchen e51fdf8216
AppImage release / appimage (push) Successful in 54s
Write the staff one octave below sounding pitch.
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>
2026-10-11 10:08:19 +00:00

110 lines
4.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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;
}