Files
Recorder-Tabber/src/lib/score.ts
T
Cursor Agentandchen 229a8d4723
AppImage release / appimage (push) Successful in 1m36s
Draw fingering charts foot-up, in grouped rows.
The foot and the double holes sit at the top of each narrow chart, and the mouthpiece sits at the bottom, matching hand-drawn tabs. Which holes are open or closed is unchanged. A repeat mark is shown only when the score repeats. Version 0.1.2.

Co-authored-by: catpersonisme <catpersonisme@gmail.com>
2026-10-11 08:30:50 +00:00

129 lines
3.8 KiB
TypeScript

import { formatQuarterLengths, quartersMatch } from "@/lib/duration";
export type TimeSignature = {
beats: number;
beatType: number;
};
export type ScoreEvent = {
id: string;
kind: "note" | "rest";
pitch: string | null;
quarters: number;
label: string;
tied: boolean;
};
export type Measure = {
id: string;
events: ScoreEvent[];
/** A forward repeat barline stands at the start of this measure. */
repeatForward?: boolean;
/** A backward repeat ends on this measure. The number is how many times to play the passage. */
repeatTimes?: number;
};
export type ScoreOrigin = "sample" | "upload" | "blank";
export type Score = {
title: string;
attribution: string;
timeSignature: TimeSignature;
measures: Measure[];
origin: ScoreOrigin;
};
export const TIME_SIGNATURES: TimeSignature[] = [
{ beats: 2, beatType: 4 },
{ beats: 3, beatType: 4 },
{ beats: 4, beatType: 4 },
{ beats: 6, beatType: 8 },
{ beats: 3, beatType: 8 },
{ beats: 2, beatType: 2 },
];
export function newId(prefix: string): string {
return `${prefix}-${Math.random().toString(36).slice(2, 10)}`;
}
export function signatureKey(timeSignature: TimeSignature): string {
return `${timeSignature.beats}/${timeSignature.beatType}`;
}
export function parseSignature(key: string): TimeSignature {
const [beats, beatType] = key.split("/").map(Number);
return { beats, beatType };
}
export function expectedQuarters(timeSignature: TimeSignature): number {
return (timeSignature.beats * 4) / timeSignature.beatType;
}
export function barStatus(
measure: Measure,
timeSignature: TimeSignature,
): { ok: boolean; text: string } {
const sum = measure.events.reduce((total, event) => total + event.quarters, 0);
const expected = expectedQuarters(timeSignature);
const signature = signatureKey(timeSignature);
if (quartersMatch(sum, expected)) {
return { ok: true, text: `${signature} · ${formatQuarterLengths(sum)}` };
}
return {
ok: false,
text: `${formatQuarterLengths(sum)} in this bar; ${signature} holds ${formatQuarterLengths(expected)}.`,
};
}
export function startBeats(events: ScoreEvent[], timeSignature: TimeSignature): number[] {
const quartersPerBeat = 4 / timeSignature.beatType;
let cursor = 0;
return events.map((event) => {
const beat = cursor / quartersPerBeat + 1;
cursor += event.quarters;
return beat;
});
}
export function formatBeat(beat: number): string {
const rounded = Math.round(beat * 100) / 100;
return Number.isInteger(rounded) ? String(rounded) : String(rounded);
}
/** Extra full beats after the note itself. A half note in 4/4 returns 1. A dotted quarter returns 0. */
export function extraBeatArrows(quarters: number, timeSignature: TimeSignature): number {
const perBeat = 4 / timeSignature.beatType;
if (!(perBeat > 0) || !(quarters > 0)) return 0;
const wholeBeats = Math.floor(quarters / perBeat + 1e-6);
return Math.max(0, wholeBeats - 1);
}
/** Group a lead into horizontal rows. A row holds about `budget` notes, and never splits a bar. */
export function leadRows<T extends { events: { length: number } }>(measures: T[], budget = 14): T[][] {
const rows: T[][] = [];
let current: T[] = [];
let count = 0;
for (const measure of measures) {
const notes = Math.max(1, measure.events.length);
if (current.length > 0 && count + notes > budget) {
rows.push(current);
current = [];
count = 0;
}
current.push(measure);
count += notes;
}
if (current.length > 0) rows.push(current);
return rows;
}
export function blankScore(): Score {
return {
title: "Untitled line",
attribution: "Hand-entered lead line. Add notes, then read the tabs.",
timeSignature: { beats: 4, beatType: 4 },
origin: "blank",
measures: [{ id: newId("m"), events: [] }],
};
}